mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 02:38:11 +03:00
Compare commits
87
Commits
1.36.0
...
df2cd3c869
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df2cd3c869 | ||
|
|
0e93d15b73 | ||
|
|
d1cbd027cd | ||
|
|
1f802f8e6a | ||
|
|
a2efadc650 | ||
|
|
acbf49018f | ||
|
|
b8089e31c2 | ||
|
|
8647af8f89 | ||
|
|
c687cacb6b | ||
|
|
8b56926077 | ||
|
|
de6d2066b3 | ||
|
|
415df400f4 | ||
|
|
42aa3ee1c7 | ||
|
|
7c188967e7 | ||
|
|
895ec2b1e7 | ||
|
|
1f99d9d276 | ||
|
|
f4f1a8e105 | ||
|
|
1c89e1772b | ||
|
|
3714b504f6 | ||
|
|
f9f011d50c | ||
|
|
cb89088bc3 | ||
|
|
4444442075 | ||
|
|
061694d0cb | ||
|
|
32098ca7d1 | ||
|
|
d2660324e6 | ||
|
|
cc67d644f6 | ||
|
|
3347698712 | ||
|
|
a24683636a | ||
|
|
64c56411f6 | ||
|
|
c3f5477525 | ||
|
|
9c8aa2359f | ||
|
|
8719f86fcf | ||
|
|
fddc8e71be | ||
|
|
eb212e23fa | ||
|
|
25dfedafd7 | ||
|
|
e992cbb4f5 | ||
|
|
5b51b60f94 | ||
|
|
de7abaaafa | ||
|
|
b7667e27bf | ||
|
|
f1c36b8c1d | ||
|
|
f1ff613008 | ||
|
|
57fbed1bed | ||
|
|
277e1536eb | ||
|
|
2ffad8775d | ||
|
|
32d85d03bb | ||
|
|
a6c3bd6d18 | ||
|
|
6729e83521 | ||
|
|
fdc156b247 | ||
|
|
2073c03092 | ||
|
|
923f5d0b5e | ||
|
|
83724b301e | ||
|
|
10e044f563 | ||
|
|
fa2566d14f | ||
|
|
46d71107f5 | ||
|
|
9e78911a2f | ||
|
|
0cefa4cca7 | ||
|
|
b30cc08562 | ||
|
|
55f883a566 | ||
|
|
74ceaf2354 | ||
|
|
2629bcbe13 | ||
|
|
46ae59eaf4 | ||
|
|
a6a88e7929 | ||
|
|
5040bcb7c0 | ||
|
|
660faee68e | ||
|
|
683a23e43c | ||
|
|
4a9bcb0694 | ||
|
|
169aa5efcc | ||
|
|
64d28ab66e | ||
|
|
4720cdbe86 | ||
|
|
5447ee6af2 | ||
|
|
5c5e8e1a6f | ||
|
|
7320a1db4b | ||
|
|
a058a35ccd | ||
|
|
a16b5afaaa | ||
|
|
fddc16d2b8 | ||
|
|
ec7fa137b7 | ||
|
|
b25f715364 | ||
|
|
d6a3d539ed | ||
|
|
d626ea81ab | ||
|
|
1ba2c6a26c | ||
|
|
22f5e0496c | ||
|
|
70f9dfbe8b | ||
|
|
54895ad4be | ||
|
|
a057c7deae | ||
|
|
2f85b62d2f | ||
|
|
9bc14e6e77 | ||
|
|
cdf711bb30 |
No files matched your search
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Ignore everything
|
||||
# Ignore everything
|
||||
*
|
||||
|
||||
// Allow what is needed
|
||||
# Allow what is needed
|
||||
!.git
|
||||
!docker/healthcheck.sh
|
||||
!docker/start.sh
|
||||
|
||||
+43
-22
@@ -50,10 +50,11 @@
|
||||
#########################
|
||||
|
||||
## Database URL
|
||||
## When using SQLite, this is the path to the DB file, and it defaults to
|
||||
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this
|
||||
## must be set to a local sqlite3 file path.
|
||||
# DATABASE_URL=data/db.sqlite3
|
||||
## When using SQLite, this should use the sqlite:// scheme followed by the path
|
||||
## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
|
||||
## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
|
||||
## but only if the database file already exists.
|
||||
# DATABASE_URL=sqlite://data/db.sqlite3
|
||||
## When using MySQL, specify an appropriate connection URI.
|
||||
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
||||
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
||||
@@ -179,7 +180,7 @@
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
|
||||
##
|
||||
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||
#
|
||||
@@ -315,6 +316,14 @@
|
||||
## unauthenticated access to potentially sensitive data.
|
||||
# SHOW_PASSWORD_HINT=false
|
||||
|
||||
#########################
|
||||
### Client settings ###
|
||||
#########################
|
||||
|
||||
## Control whether clients onboarding interstitials are suppressed
|
||||
## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals)
|
||||
# CLIENT_SUPPRESS_ONBOARDING=false
|
||||
|
||||
#########################
|
||||
### Advanced settings ###
|
||||
#########################
|
||||
@@ -323,6 +332,16 @@
|
||||
## Set to the string "none" (without quotes), to disable any headers and just use the remote 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.
|
||||
## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of
|
||||
## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them.
|
||||
# IP_HEADER_TRUSTED_PROXIES=local
|
||||
|
||||
## Icon service
|
||||
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||
@@ -373,20 +392,17 @@
|
||||
##
|
||||
## The following flags are available:
|
||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
|
||||
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
||||
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
||||
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
||||
## - "pm-30529-webauthn-related-origins":
|
||||
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
|
||||
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
|
||||
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
|
||||
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||
|
||||
## Require new device emails. When a user logs in an email is required to be sent.
|
||||
@@ -459,6 +475,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.
|
||||
# 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
|
||||
## Controls whether group support is enabled for organizations
|
||||
## This setting applies to organizations.
|
||||
@@ -493,6 +516,9 @@
|
||||
## Prevent users from logging in directly without going through SSO
|
||||
# SSO_ONLY=false
|
||||
|
||||
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
|
||||
# SSO_SIGNUPS_ALLOWED=true
|
||||
|
||||
## On SSO Signup if a user with a matching email already exists make the association
|
||||
# SSO_SIGNUPS_MATCH_EMAIL=true
|
||||
|
||||
@@ -555,11 +581,6 @@
|
||||
# DUO_HOST=<API Hostname>
|
||||
## After that, you should be able to follow the rest of the guide linked above,
|
||||
## ignoring the fields that ask for the values that you already configured beforehand.
|
||||
##
|
||||
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
|
||||
## Duo no longer supports this, but it still works for some integrations.
|
||||
## If you aren't sure, leave this alone.
|
||||
# DUO_USE_IFRAME=false
|
||||
|
||||
## Email 2FA settings
|
||||
## Email token size
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
# Enable Rust Caching
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -30,24 +30,25 @@ jobs:
|
||||
driver-opts: |
|
||||
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
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# Test Dockerfiles with hadolint
|
||||
- name: Run hadolint
|
||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
||||
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
||||
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
||||
- name: Run hadolint on Dockerfile.debian
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||
with:
|
||||
dockerfile: docker/Dockerfile.debian
|
||||
|
||||
- name: Run hadolint on Dockerfile.alpine
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
|
||||
with:
|
||||
dockerfile: docker/Dockerfile.alpine
|
||||
# End Test Dockerfiles with hadolint
|
||||
|
||||
# Test Dockerfiles with docker build checks
|
||||
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Initialize QEMU binfmt support
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
with:
|
||||
platforms: "arm64,arm"
|
||||
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@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
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
# 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:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
|
||||
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||
env:
|
||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: Attest binaries
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
|
||||
merge-manifests:
|
||||
name: Merge manifests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: docker-build
|
||||
environment:
|
||||
name: release
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
|
||||
# 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:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -303,7 +303,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
# Attest container images
|
||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
@@ -373,7 +373,7 @@ jobs:
|
||||
|
||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.GHCR_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
@@ -381,7 +381,7 @@ jobs:
|
||||
|
||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.QUAY_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -50,6 +50,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2
|
||||
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||
@@ -14,17 +14,17 @@ on:
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
security-events: write # To write the security report
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
# intentionally not scanning the entire repository,
|
||||
# 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
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: 7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2
|
||||
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||
hooks:
|
||||
- id: typos
|
||||
always_run: true
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
@@ -38,8 +39,7 @@ repos:
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
@@ -47,8 +47,7 @@ repos:
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||
types_or: [ rust, file ]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
|
||||
Generated
+1109
-1236
File diff suppressed because it is too large.
Load diff
+156
-92
@@ -1,6 +1,6 @@
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.93.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97.1"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
publish = false
|
||||
@@ -12,9 +12,7 @@ members = ["macros"]
|
||||
name = "vaultwarden"
|
||||
version = "1.0.0"
|
||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||
readme = "README.md"
|
||||
build = "build.rs"
|
||||
resolver = "2"
|
||||
repository.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -23,7 +21,8 @@ publish.workspace = true
|
||||
|
||||
[features]
|
||||
default = [
|
||||
# "sqlite" or "sqlite_system",
|
||||
# "sqlite",
|
||||
# "sqlite_system",
|
||||
# "mysql",
|
||||
# "postgresql",
|
||||
]
|
||||
@@ -32,14 +31,23 @@ enable_syslog = []
|
||||
# Please enable at least one of these DB backends.
|
||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"]
|
||||
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Alternative to the above, statically linked SQLite into the binary instead of dynamically.
|
||||
sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"] # Dynamically link SQLite
|
||||
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Statically link SQLite into the binary instead of dynamically.
|
||||
# Enable to use a vendored and statically linked openssl
|
||||
vendored_openssl = ["openssl/vendored"]
|
||||
# Enable MiMalloc memory allocator to replace the default malloc
|
||||
# This can improve performance for Alpine builds
|
||||
enable_mimalloc = ["dep:mimalloc"]
|
||||
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
|
||||
s3 = [
|
||||
"opendal/services-s3",
|
||||
"dep:opendal-http-transport-reqwest",
|
||||
"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-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
||||
@@ -50,6 +58,7 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
|
||||
unstable = []
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
nix = { version = "0.31.3", features = ["fs"] }
|
||||
# Logging
|
||||
syslog = "7.0.0"
|
||||
|
||||
@@ -57,75 +66,90 @@ syslog = "7.0.0"
|
||||
macros = { path = "./macros" }
|
||||
|
||||
# Logging
|
||||
log = "0.4.29"
|
||||
log = "0.4.34"
|
||||
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
|
||||
dotenvy = { version = "0.15.7", default-features = false }
|
||||
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.19"
|
||||
num-derive = "0.4.2"
|
||||
bigdecimal = "0.4.10"
|
||||
num-derive = "0.5.1"
|
||||
bigdecimal = "0.4.11"
|
||||
|
||||
# Web framework
|
||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
||||
rocket_ws = { version ="0.1.1" }
|
||||
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||
rocket_ws = { version = "0.1.1" }
|
||||
|
||||
# WebSockets libraries
|
||||
rmpv = "1.3.1" # MessagePack library
|
||||
|
||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||
dashmap = "6.1.0"
|
||||
dashmap = "6.2.1"
|
||||
|
||||
# Async futures
|
||||
futures = "0.3.32"
|
||||
tokio = { version = "1.52.1", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
tokio-util = { version = "0.7.18", features = ["compat"]}
|
||||
futures = "0.3.34"
|
||||
tokio = { version = "1.53.2", features = [
|
||||
"fs",
|
||||
"io-util",
|
||||
"net",
|
||||
"parking_lot",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-util = { version = "0.7.19", features = ["compat"] }
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
# 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.9", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.3.2"
|
||||
|
||||
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
||||
diesel-derive-newtype = "2.1.2"
|
||||
derive_more = { version = "2.1.1", features = [
|
||||
"as_ref",
|
||||
"deref",
|
||||
"display",
|
||||
"from",
|
||||
"into",
|
||||
] }
|
||||
diesel-derive-newtype = "2.1.3"
|
||||
|
||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
||||
libsqlite3-sys = { version = "0.38.2", optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = "0.10.1"
|
||||
rand = "0.10.3"
|
||||
ring = "0.17.14"
|
||||
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.23.1", features = ["v4"] }
|
||||
uuid = { version = "1.27.0", features = ["v4"] }
|
||||
|
||||
# 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"
|
||||
time = "0.3.47"
|
||||
time = "0.3.55"
|
||||
|
||||
# Job scheduler
|
||||
job_scheduler_ng = "2.4.0"
|
||||
job_scheduler_ng = "2.5.0"
|
||||
|
||||
# Data encoding library Hex/Base32/Base64
|
||||
data-encoding = "2.11.0"
|
||||
data-encoding = "2.11.1"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||
|
||||
# TOTP library
|
||||
totp-lite = "2.0.1"
|
||||
|
||||
# Yubico Library
|
||||
yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false }
|
||||
yubico_ng = { version = "1.0.0", default-features = false }
|
||||
|
||||
# WebAuthn libraries
|
||||
# danger-allow-state-serialisation is needed to save the state in the db
|
||||
@@ -138,73 +162,116 @@ webauthn-rs-core = "0.5.5"
|
||||
url = "2.5.8"
|
||||
|
||||
# Email libraries
|
||||
lettre = { version = "0.11.21", 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
|
||||
email_address = "0.2.9"
|
||||
|
||||
# HTML Template library
|
||||
handlebars = { version = "6.4.0", features = ["dir_source"] }
|
||||
handlebars = { version = "6.4.4", features = ["dir_source"] }
|
||||
|
||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
||||
hickory-resolver = "0.26.1"
|
||||
reqwest = { version = "0.13.5", 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.3"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.8.3"
|
||||
regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false }
|
||||
html5gum = "0.8.4"
|
||||
regex = { version = "1.13.1", default-features = false, features = [
|
||||
"perf",
|
||||
"std",
|
||||
"unicode-perl",
|
||||
] }
|
||||
data-url = "0.3.2"
|
||||
bytes = "1.11.1"
|
||||
svg-hush = "0.9.6"
|
||||
bytes = "1.12.1"
|
||||
svg-hush = "0.9.7"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "0.59.0", features = ["async"] }
|
||||
cached = { version = "4.0.1", features = ["async"] }
|
||||
|
||||
# Used for custom short lived cookie jar during favicon extraction
|
||||
cookie = "0.18.1"
|
||||
cookie = "0.18.2"
|
||||
cookie_store = "0.22.1"
|
||||
|
||||
# Used by U2F, JWT and PostgreSQL
|
||||
openssl = "0.10.78"
|
||||
openssl = "0.10.81"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
|
||||
# Macro ident concatenation
|
||||
pastey = "0.2.2"
|
||||
pastey = "0.2.3"
|
||||
governor = "0.10.4"
|
||||
|
||||
# CIDR parsing for the trusted proxies of the client IP header
|
||||
ipnet = "2.12.2"
|
||||
|
||||
# OIDC for SSO
|
||||
openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] }
|
||||
moka = { version = "0.12.15", features = ["future"] }
|
||||
openidconnect = { version = "4.0.1", default-features = false }
|
||||
moka = { version = "0.12.16", features = ["future"] }
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.28"
|
||||
|
||||
# Allow overriding the default memory allocator
|
||||
# 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.6"
|
||||
|
||||
# Argon2 library with support for the PHC format
|
||||
argon2 = "0.5.3"
|
||||
argon2 = "0.6.0"
|
||||
|
||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||
rpassword = "7.5.1"
|
||||
rpassword = "7.5.4"
|
||||
|
||||
# Loading a dynamic CSS Stylesheet
|
||||
grass_compiler = { version = "0.13.4", default-features = false }
|
||||
|
||||
# File are accessed through Apache OpenDAL
|
||||
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false }
|
||||
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
|
||||
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||
|
||||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
anyhow = { version = "1.0.102", optional = true }
|
||||
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-credential-types = { version = "1.2.14", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
||||
http = { version = "1.4.0", optional = true }
|
||||
reqsign = { version = "0.16.5", optional = true }
|
||||
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||
"behavior-version-latest",
|
||||
"credentials-process",
|
||||
"rt-tokio",
|
||||
"sso",
|
||||
] }
|
||||
aws-credential-types = { version = "1.3.0", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
|
||||
http = { version = "1.5.0", optional = true }
|
||||
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
||||
reqsign-core = { version = "3.3.2", optional = true }
|
||||
|
||||
# Strip debuginfo from the release builds
|
||||
# The debug symbols are to provide better panic traces
|
||||
@@ -264,77 +331,74 @@ unsafe_code = "forbid"
|
||||
non_ascii_idents = "forbid"
|
||||
|
||||
# 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 }
|
||||
future_incompatible = { level = "deny", priority = -1 }
|
||||
keyword_idents = { level = "deny", priority = -1 }
|
||||
let_underscore = { level = "deny", priority = -1 }
|
||||
nonstandard_style = { level = "deny", priority = -1 }
|
||||
noop_method_call = "deny"
|
||||
refining_impl_trait = { level = "deny", priority = -1 }
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
rust_2021_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"
|
||||
trivial_casts = "deny"
|
||||
trivial_numeric_casts = "deny"
|
||||
unused = { level = "deny", priority = -1 }
|
||||
unused_import_braces = "deny"
|
||||
unused_lifetimes = "deny"
|
||||
unused_qualifications = "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
|
||||
[workspace.lints.clippy]
|
||||
# Warn
|
||||
# Warn only so you can still use these during development, but not in the final code
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Ignore/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"
|
||||
case_sensitive_file_extension_comparisons = "deny"
|
||||
cast_lossless = "deny"
|
||||
clone_on_ref_ptr = "deny"
|
||||
duration_suboptimal_units = "deny"
|
||||
equatable_if_let = "deny"
|
||||
excessive_precision = "deny"
|
||||
filter_map_next = "deny"
|
||||
float_cmp_const = "deny"
|
||||
implicit_clone = "deny"
|
||||
inefficient_to_string = "deny"
|
||||
iter_on_empty_collections = "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"
|
||||
needless_borrow = "deny"
|
||||
needless_collect = "deny"
|
||||
needless_continue = "deny"
|
||||
needless_lifetimes = "deny"
|
||||
option_option = "deny"
|
||||
redundant_clone = "deny"
|
||||
ref_option = "deny"
|
||||
string_add_assign = "deny"
|
||||
unnecessary_join = "deny"
|
||||
unnecessary_self_imports = "deny"
|
||||
unnested_or_patterns = "deny"
|
||||
unused_async = "deny"
|
||||
unused_self = "deny"
|
||||
useless_let_if_seq = "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]
|
||||
workspace = true
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
use std::{env, io::Error, process::Command};
|
||||
|
||||
fn main() {
|
||||
// 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()?;
|
||||
if !out.status.success() {
|
||||
use std::io::Error;
|
||||
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
|
||||
@@ -58,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
||||
/// - `env!("GIT_BRANCH")`
|
||||
/// - `env!("GIT_REV")`
|
||||
/// - `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 current commit doesn't have an associated tag
|
||||
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
vault_version: "v2026.4.1"
|
||||
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
|
||||
vault_version: "v2026.8.0"
|
||||
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
|
||||
# Cross Compile Docker Helper Scripts v1.9.0
|
||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||
rust_version: 1.95.0 # Rust version to be used
|
||||
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||
# rust_version: x.yy.z # Rust version to be used
|
||||
debian_version: trixie # Debian release name to be used
|
||||
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
|
||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||
# Determine the build images per OS/Arch
|
||||
|
||||
+17
-17
@@ -19,23 +19,23 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
||||
|
||||
########################## ALPINE BUILD IMAGES ##########################
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 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:armv7-musleabihf-stable-1.95.0 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:x86_64-musl-stable-1.99.0 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
@@ -57,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
# Debian Trixie uses libpq v17
|
||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||
|
||||
|
||||
# Create CARGO_HOME folder and don't download rust docs
|
||||
RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
rustup set profile minimal
|
||||
@@ -67,11 +66,11 @@ RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
# Environment variables for Cargo on Alpine based builds
|
||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
||||
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
|
||||
RUN source /env-cargo && \
|
||||
RUN . /env-cargo && \
|
||||
rustup target add "${CARGO_TARGET}"
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
@@ -87,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
||||
# Builds your dependencies and removes the
|
||||
# dummy project, except the target folder
|
||||
# This folder contains the compiled dependencies
|
||||
RUN source /env-cargo && \
|
||||
RUN . /env-cargo && \
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
@@ -98,13 +97,13 @@ COPY . .
|
||||
ARG VW_VERSION
|
||||
|
||||
# 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
|
||||
# Also do this for build.rs to ensure the version is rechecked
|
||||
touch build.rs src/main.rs && \
|
||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||
else \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||
@@ -127,7 +126,8 @@ RUN source /env-cargo && \
|
||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
#
|
||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||
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" \
|
||||
ROCKET_ADDRESS=0.0.0.0 \
|
||||
|
||||
+50
-44
@@ -19,15 +19,15 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
|
||||
# [docker.io/vaultwarden/web-vault:v2026.4.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper 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 ##########################
|
||||
# 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.99.0-slim-trixie AS build
|
||||
# hadolint ignore=DL3067
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -51,7 +52,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-256color \
|
||||
CARGO_HOME="/root/.cargo" \
|
||||
USER="root"
|
||||
# Install clang to get `xx-cargo` working
|
||||
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||
# Install pkg-config to allow amd64 builds to find all libraries
|
||||
# Install git so build.rs can determine the correct version
|
||||
# Install the libc cross packages based upon the debian-arch
|
||||
@@ -59,19 +60,16 @@ RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
--no-install-recommends \
|
||||
clang \
|
||||
pkg-config \
|
||||
git \
|
||||
"libc6-$(xx-info debian-arch)-cross" \
|
||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
||||
git && \
|
||||
xx-apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
libssl-dev \
|
||||
libmariadb-dev \
|
||||
zlib1g-dev && \
|
||||
pkg-config \
|
||||
zlib1g-dev \
|
||||
xx-c-essentials && \
|
||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||
|
||||
@@ -83,30 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
|
||||
RUN source /env-cargo && \
|
||||
RUN . /env-cargo && \
|
||||
rustup target add "${CARGO_TARGET}"
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
@@ -121,8 +96,23 @@ ARG DB=sqlite,mysql,postgresql
|
||||
# Builds your dependencies and removes the
|
||||
# dummy project, except the target folder
|
||||
# This folder contains the compiled dependencies
|
||||
RUN source /env-cargo && \
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
RUN . /env-cargo && \
|
||||
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||
if xx-info is-cross; then \
|
||||
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
fi && \
|
||||
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||
fi && \
|
||||
export XX_RUSTFLAGS && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
@@ -132,13 +122,28 @@ COPY . .
|
||||
ARG VW_VERSION
|
||||
|
||||
# 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
|
||||
# Also do this for build.rs to ensure the version is rechecked
|
||||
touch build.rs src/main.rs && \
|
||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||
if xx-info is-cross; then \
|
||||
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
fi && \
|
||||
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||
fi && \
|
||||
export XX_RUSTFLAGS && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||
else \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||
@@ -161,6 +166,7 @@ RUN source /env-cargo && \
|
||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
#
|
||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||
# hadolint ignore=DL3065
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
||||
|
||||
ENV ROCKET_PROFILE="release" \
|
||||
|
||||
+40
-39
@@ -27,6 +27,24 @@
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||
#
|
||||
{% macro xx_cargo_config() -%}
|
||||
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
|
||||
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||
# https://github.com/dani-garcia/vaultwarden/discussions/7522
|
||||
if xx-info is-cross; then \
|
||||
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
fi && \
|
||||
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
|
||||
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
|
||||
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
|
||||
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
|
||||
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
|
||||
if [ "$(xx-info arch)" = "arm64" ]; then \
|
||||
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
|
||||
fi && \
|
||||
export XX_RUSTFLAGS && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
||||
{%- endmacro %}
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||
|
||||
{% if base == "debian" %}
|
||||
@@ -47,6 +65,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||
{% if base == "debian" %}
|
||||
# hadolint ignore=DL3067
|
||||
COPY --from=xx / /
|
||||
{% endif %}
|
||||
ARG TARGETARCH
|
||||
@@ -66,10 +85,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
||||
# Debian Trixie uses libpq v17
|
||||
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
|
||||
{% if base == "debian" %}
|
||||
# Install clang to get `xx-cargo` working
|
||||
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||
# Install pkg-config to allow amd64 builds to find all libraries
|
||||
# Install git so build.rs can determine the correct version
|
||||
# Install the libc cross packages based upon the debian-arch
|
||||
@@ -77,19 +96,16 @@ RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
--no-install-recommends \
|
||||
clang \
|
||||
pkg-config \
|
||||
git \
|
||||
"libc6-$(xx-info debian-arch)-cross" \
|
||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
||||
git && \
|
||||
xx-apt-get install -y \
|
||||
--no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
libssl-dev \
|
||||
libmariadb-dev \
|
||||
zlib1g-dev && \
|
||||
pkg-config \
|
||||
zlib1g-dev \
|
||||
xx-c-essentials && \
|
||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
||||
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||
{% endif %}
|
||||
@@ -102,38 +118,14 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
||||
RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
{% if base == "debian" %}
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
|
||||
{% elif base == "alpine" %}
|
||||
{% if base == "alpine" %}
|
||||
# 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
|
||||
cat /env-cargo
|
||||
|
||||
{% endif %}
|
||||
RUN source /env-cargo && \
|
||||
RUN . /env-cargo && \
|
||||
rustup target add "${CARGO_TARGET}"
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
@@ -153,8 +145,12 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
||||
# Builds your dependencies and removes the
|
||||
# dummy project, except the target folder
|
||||
# This folder contains the compiled dependencies
|
||||
RUN source /env-cargo && \
|
||||
RUN . /env-cargo && \
|
||||
{% if base == "debian" %}
|
||||
{{ xx_cargo_config() }} && \
|
||||
{% elif base == "alpine" %}
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
{% endif %}
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
@@ -164,13 +160,17 @@ COPY . .
|
||||
ARG VW_VERSION
|
||||
|
||||
# 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
|
||||
# Also do this for build.rs to ensure the version is rechecked
|
||||
touch build.rs src/main.rs && \
|
||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||
{% if base == "debian" %}
|
||||
{{ xx_cargo_config() }} && \
|
||||
{% elif base == "alpine" %}
|
||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||
{% endif %}
|
||||
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||
else \
|
||||
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
|
||||
@@ -193,6 +193,7 @@ RUN source /env-cargo && \
|
||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
#
|
||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||
# hadolint ignore=DL3065
|
||||
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
|
||||
|
||||
ENV ROCKET_PROFILE="release" \
|
||||
|
||||
@@ -3,17 +3,23 @@
|
||||
import os
|
||||
import argparse
|
||||
import json
|
||||
import tomllib
|
||||
import yaml
|
||||
import jinja2
|
||||
|
||||
# Load settings file
|
||||
with open("DockerSettings.yaml", 'r') as yaml_file:
|
||||
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
|
||||
yaml_data = yaml.safe_load(yaml_file)
|
||||
|
||||
# Extract the rust_version from the rust-toolchain.toml file
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
|
||||
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
|
||||
|
||||
settings_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(os.getcwd()),
|
||||
)
|
||||
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
|
||||
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
|
||||
|
||||
args_parser = argparse.ArgumentParser()
|
||||
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.45"
|
||||
syn = "2.0.117"
|
||||
quote = "1.0.47"
|
||||
syn = "3.0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+5
-4
@@ -1,14 +1,15 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{DeriveInput, parse_macro_input};
|
||||
|
||||
#[proc_macro_derive(UuidFromParam)]
|
||||
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)
|
||||
}
|
||||
|
||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
|
||||
#[proc_macro_derive(IdFromParam)]
|
||||
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)
|
||||
}
|
||||
|
||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||
@@ -1,15 +1,31 @@
|
||||
-- Dynamically create DROP FOREIGN KEY
|
||||
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
|
||||
-- This checks if the key exists, and if so, will drop it.
|
||||
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
|
||||
CONSTRAINT_SCHEMA = DATABASE() AND
|
||||
TABLE_NAME = 'sso_users' AND
|
||||
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
|
||||
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
|
||||
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @drop_sso_fk;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
SELECT if (
|
||||
EXISTS(
|
||||
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sso_users'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
|
||||
)
|
||||
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
|
||||
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
|
||||
) INTO @drop_stmt;
|
||||
PREPARE drop_stmt FROM @drop_stmt;
|
||||
EXECUTE drop_stmt;
|
||||
|
||||
SELECT if (
|
||||
EXISTS(
|
||||
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sso_users'
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
||||
AND CONSTRAINT_NAME = '1'
|
||||
)
|
||||
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
|
||||
,'SELECT "info: FK sso_users 1 does not exist."'
|
||||
) INTO @drop_stmt;
|
||||
PREPARE drop_stmt FROM @drop_stmt;
|
||||
EXECUTE drop_stmt;
|
||||
|
||||
DEALLOCATE PREPARE drop_stmt;
|
||||
|
||||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE org_policies DROP COLUMN revision_date;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date
|
||||
-- columns elsewhere, and to avoid MySQL's implicit session-timezone
|
||||
-- conversion and 2038 range limit on TIMESTAMP.
|
||||
ALTER TABLE org_policies
|
||||
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||
|
||||
UPDATE org_policies SET revision_date = UTC_TIMESTAMP();
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE org_policies DROP COLUMN revision_date;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now():
|
||||
-- assigning timestamptz now() into a naive TIMESTAMP column casts through
|
||||
-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock
|
||||
-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp
|
||||
-- column in this schema.
|
||||
ALTER TABLE org_policies
|
||||
ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||
|
||||
UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc');
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN code_response_error;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN key_id TEXT;
|
||||
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
||||
-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add
|
||||
-- the column with a constant placeholder and backfill separately.
|
||||
ALTER TABLE org_policies
|
||||
ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||
|
||||
UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP;
|
||||
@@ -21,11 +21,19 @@ TEST_USER3=test3
|
||||
TEST_USER3_PASSWORD=${TEST_USER3}
|
||||
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
|
||||
|
||||
TEST_USER4=test4
|
||||
TEST_USER4_PASSWORD=${TEST_USER4}
|
||||
TEST_USER4_MAIL=${TEST_USER4}@yopmail.com
|
||||
|
||||
TEST_USER5=test5
|
||||
TEST_USER5_PASSWORD=${TEST_USER5}
|
||||
TEST_USER5_MAIL=${TEST_USER5}@yopmail.com
|
||||
|
||||
###################
|
||||
# Keycloak Config #
|
||||
###################
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||
KC_HTTP_HOST=127.0.0.1
|
||||
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_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
|
||||
SSO_DEBUG_TOKENS=true
|
||||
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||
|
||||
SSO_ENABLED=true
|
||||
|
||||
+15
-17
@@ -1,8 +1,8 @@
|
||||
# Integration tests
|
||||
|
||||
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 uses its own [test.env](./test.env) with different ports to not collide with a running dev instance.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
|
||||
|
||||
### Running Playwright outside docker
|
||||
|
||||
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
|
||||
You will additionally need `nodejs` then run:
|
||||
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
|
||||
You'll additionally need `nodejs` then run:
|
||||
|
||||
```bash
|
||||
npm ci --ignore-scripts
|
||||
npm ci --ignore-scripts --allow-git=none --allow-remote=none
|
||||
npx playwright install-deps
|
||||
npx playwright install firefox
|
||||
```
|
||||
@@ -62,10 +62,10 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
||||
|
||||
### Keep services running
|
||||
|
||||
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||
If you want you can keep the DB and Keycloak running (states are not impacted by the tests):
|
||||
|
||||
```bash
|
||||
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
||||
PW_KEEP_SERVICE_RUNNING=true npx playwright test
|
||||
```
|
||||
|
||||
### Running specific tests
|
||||
@@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
|
||||
```
|
||||
|
||||
To run only a specifc test (It might fail if it has dependency):
|
||||
To run only a specific test (It might fail if it has dependency):
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
|
||||
@@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually.
|
||||
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||
npx playwright codegen "http://127.0.0.1:8003"
|
||||
npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Additionally this `docker-compose` template allows to run locally Vaultwarden,
|
||||
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
||||
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.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
|
||||
```bash
|
||||
> 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 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
||||
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
|
||||
(It is normal for this container to stop once the configuration is done).
|
||||
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).
|
||||
|
||||
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`
|
||||
- `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).
|
||||
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_*`)
|
||||
|
||||
## Cleanup
|
||||
|
||||
@@ -17,7 +17,7 @@ done
|
||||
|
||||
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 clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
||||
@@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi
|
||||
# THEN in another terminal:
|
||||
# docker exec -it keycloakSetup-dev /bin/bash
|
||||
# export PATH=$PATH:/opt/keycloak/bin
|
||||
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$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
|
||||
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
||||
@@ -28,7 +28,7 @@ RUN mkdir /playwright
|
||||
WORKDIR /playwright
|
||||
|
||||
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 compose ./compose
|
||||
|
||||
@@ -35,6 +35,7 @@ WORKDIR /
|
||||
|
||||
COPY --from=prebuilt /start.sh .
|
||||
COPY --from=prebuilt /vaultwarden .
|
||||
COPY --from=build /data ./data
|
||||
COPY --from=build /web-vault ./web-vault
|
||||
|
||||
ENTRYPOINT ["/start.sh"]
|
||||
@@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
||||
|
||||
mv build /web-vault
|
||||
fi
|
||||
|
||||
# Lower the KDF iterations default for faster tests.
|
||||
sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js
|
||||
|
||||
# Generate a self signed cert
|
||||
mkdir -p /data/ssl; cd /data/ssl
|
||||
|
||||
openssl req -x509 -out localhost.crt -keyout localhost.key \
|
||||
-newkey rsa:2048 -nodes -sha256 \
|
||||
-subj '/CN=localhost' -extensions EXT -config <( \
|
||||
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
|
||||
@@ -24,12 +24,15 @@ services:
|
||||
environment:
|
||||
- ADMIN_TOKEN
|
||||
- DATABASE_URL
|
||||
- CLIENT_SUPPRESS_ONBOARDING
|
||||
- EMAIL_2FA_AUTO_FALLBACK
|
||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||
- LOG_LEVEL
|
||||
- LOGIN_RATELIMIT_MAX_BURST
|
||||
- SMTP_HOST
|
||||
- SMTP_FROM
|
||||
- SMTP_DEBUG
|
||||
- SSO_AUTH_ONLY_NOT_SESSION
|
||||
- SSO_DEBUG_TOKENS
|
||||
- SSO_ENABLED
|
||||
- SSO_FRONTEND
|
||||
@@ -58,7 +61,7 @@ services:
|
||||
Mariadb:
|
||||
profiles: ["playwright"]
|
||||
container_name: playwright_mariadb
|
||||
image: mariadb:11.2.4
|
||||
image: mariadb:12.2.2
|
||||
env_file: test.env
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
@@ -70,7 +73,7 @@ services:
|
||||
Mysql:
|
||||
profiles: ["playwright"]
|
||||
container_name: playwright_mysql
|
||||
image: mysql:8.4.1
|
||||
image: mysql:9.7.0
|
||||
env_file: test.env
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
@@ -82,7 +85,7 @@ services:
|
||||
Postgres:
|
||||
profiles: ["playwright"]
|
||||
container_name: playwright_postgres
|
||||
image: postgres:16.3
|
||||
image: postgres:18.4
|
||||
env_file: test.env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
@@ -94,7 +97,7 @@ services:
|
||||
Maildev:
|
||||
profiles: ["vaultwarden", "maildev"]
|
||||
container_name: maildev
|
||||
image: timshel/maildev:3.0.4
|
||||
image: timshel/maildev:3.2.19
|
||||
ports:
|
||||
- ${SMTP_PORT}:1025
|
||||
- 1080:1080
|
||||
@@ -102,7 +105,7 @@ services:
|
||||
Keycloak:
|
||||
profiles: ["keycloak", "vaultwarden"]
|
||||
container_name: keycloak-${ENV:-dev}
|
||||
image: quay.io/keycloak/keycloak:26.3.4
|
||||
image: quay.io/keycloak/keycloak:26.6.2
|
||||
network_mode: "host"
|
||||
command:
|
||||
- start-dev
|
||||
@@ -112,12 +115,12 @@ services:
|
||||
profiles: ["keycloak", "vaultwarden"]
|
||||
container_name: keycloakSetup-${ENV:-dev}
|
||||
image: keycloak_setup-${ENV:-dev}
|
||||
network_mode: "host"
|
||||
build:
|
||||
context: compose/keycloak
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
KEYCLOAK_VERSION: 26.3.4
|
||||
network_mode: "host"
|
||||
KEYCLOAK_VERSION: 26.6.2
|
||||
depends_on:
|
||||
- Keycloak
|
||||
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 fs from 'fs';
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
|
||||
}
|
||||
|
||||
export async function stopVault(force: boolean = false) {
|
||||
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) {
|
||||
if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
|
||||
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
|
||||
} else {
|
||||
console.log(`Vaultwarden stopping`);
|
||||
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
|
||||
}
|
||||
|
||||
export async function cleanLanding(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByRole('button').nth(0)).toBeVisible();
|
||||
|
||||
@@ -248,15 +249,3 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
export async function ignoreExtension(page: Page) {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||
} catch (error) {
|
||||
console.log('Extension setup not visible. Continuing');
|
||||
}
|
||||
|
||||
}
|
||||
Generated
+582
-580
File diff suppressed because it is too large.
Load diff
@@ -8,14 +8,14 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"dotenv": "17.2.3",
|
||||
"dotenv-expand": "12.0.3",
|
||||
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||
"@playwright/test": "1.60.0",
|
||||
"dotenv": "17.4.2",
|
||||
"dotenv-expand": "13.0.0",
|
||||
"maildev": "npm:@timshel_npm/maildev@3.2.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"mysql2": "3.15.3",
|
||||
"otpauth": "9.4.1",
|
||||
"pg": "8.16.3"
|
||||
"mysql2": "3.22.3",
|
||||
"otpauth": "9.5.1",
|
||||
"pg": "8.21.0"
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,12 @@ export default defineConfig({
|
||||
/* Long global timeout for complex tests
|
||||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
||||
*/
|
||||
timeout: 120 * 1000,
|
||||
actionTimeout: 20 * 1000,
|
||||
navigationTimeout: 20 * 1000,
|
||||
expect: { timeout: 20 * 1000 },
|
||||
timeout: 240 * 1000,
|
||||
actionTimeout: 40 * 1000,
|
||||
navigationTimeout: 40 * 1000,
|
||||
expect: { timeout: 40 * 1000 },
|
||||
|
||||
"permissions": ["clipboard-read"],
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
@@ -37,6 +39,10 @@ export default defineConfig({
|
||||
browserName: 'firefox',
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'Europe/London',
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: ['--ignore-certificate-errors']
|
||||
},
|
||||
|
||||
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on',
|
||||
|
||||
+6
-4
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
|
||||
#####################
|
||||
# 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
|
||||
|
||||
#####################
|
||||
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
|
||||
###################
|
||||
# Keycloak Config #
|
||||
###################
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
|
||||
KC_HTTP_HOST=127.0.0.1
|
||||
KC_HTTP_PORT=8081
|
||||
|
||||
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
||||
# Vaultwarden Config #
|
||||
######################
|
||||
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
|
||||
LOGIN_RATELIMIT_MAX_BURST=100
|
||||
ADMIN_TOKEN=admin
|
||||
CLIENT_SUPPRESS_ONBOARDING=true
|
||||
|
||||
SMTP_SECURITY=off
|
||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect, type TestInfo } from '@playwright/test';
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
|
||||
import * as orgs from './setups/orgs';
|
||||
import { createAccount } from './setups/user';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
|
||||
test('Create', async ({ page }) => {
|
||||
await createAccount(test, page, users.user1);
|
||||
|
||||
await test.step('Create Org', async () => {
|
||||
await page.getByRole('link', { name: 'New organisation' }).click();
|
||||
await page.getByLabel('Organisation name (required)').fill('Test');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
|
||||
|
||||
await utils.checkNotification(page, 'Organisation created');
|
||||
});
|
||||
await orgs.create(test, page, 'New organisation');
|
||||
|
||||
await test.step('Create Collection', async () => {
|
||||
await page.getByRole('link', { name: 'Collections' }).click();
|
||||
await page.getByRole('button', { name: 'New' }).click();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).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 utils.checkNotification(page, 'Created collection RandomCollec');
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { test, expect, type Page, type TestInfo, Test } from '@playwright/test';
|
||||
import { MailDev } from 'maildev';
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
import { createAccount, logUser } from './setups/user';
|
||||
import { activateTOTP } from './setups/2fa';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
let mailserver;
|
||||
|
||||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
mailserver = new MailDev({
|
||||
port: process.env.MAILDEV_SMTP_PORT,
|
||||
web: { port: process.env.MAILDEV_HTTP_PORT },
|
||||
})
|
||||
|
||||
await mailserver.listen();
|
||||
|
||||
await utils.startVault(browser, testInfo, {
|
||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async ({}) => {
|
||||
utils.stopVault();
|
||||
if( mailserver ){
|
||||
await mailserver.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function emergencyAccess(test: Test, page: Page, user: { name: string }) {
|
||||
await test.step('Navigate', async () => {
|
||||
await page.getByRole('button', { name: user.name }).click();
|
||||
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||
await page.getByRole('link', { name: 'Emergency access' }).click();
|
||||
await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('Emergency access', async ({ browser, page }) => {
|
||||
const context2 = await browser.newContext();
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||
const mailBuffer2 = mailserver.buffer(users.user2.email);
|
||||
|
||||
await createAccount(test, page, users.user1);
|
||||
await createAccount(test, page2, users.user2);
|
||||
|
||||
await test.step('Add test2', async () => {
|
||||
await emergencyAccess(test, page, users.user1);
|
||||
await page.getByRole('button', { name: 'Add emergency contact' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email);
|
||||
await page.getByRole('radio', { name: 'Takeover Can reset your' }).check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'User(s) invited');
|
||||
});
|
||||
|
||||
await test.step('Accept', async () => {
|
||||
const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name);
|
||||
const pageE = await context2.newPage();
|
||||
await pageE.setContent(email.html);
|
||||
const link = await pageE.getByTestId("emergency").getAttribute("href");
|
||||
await pageE.close();
|
||||
|
||||
await page2.goto(link);
|
||||
await utils.checkNotification(page2, 'Invitation accepted');
|
||||
});
|
||||
|
||||
await test.step('Confirm', async () => {
|
||||
await emergencyAccess(test, page, users.user1);
|
||||
await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Confirm' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await utils.checkNotification(page, users.user2.name + ' confirmed');
|
||||
await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed");
|
||||
});
|
||||
|
||||
await test.step('Request', async () => {
|
||||
await emergencyAccess(test, page2, users.user2);
|
||||
await page2.getByRole('button', { name: 'Options' }).click();
|
||||
await page2.getByRole('menuitem', { name: 'Request Access' }).click();
|
||||
await page2.getByRole('button', { name: 'Request Access' }).click();
|
||||
await utils.checkNotification(page2, 'Emergency access requested');
|
||||
await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated");
|
||||
});
|
||||
|
||||
await test.step('Approved', async () => {
|
||||
await emergencyAccess(test, page, users.user1);
|
||||
await page.getByRole('button', { name: 'Options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Approve' }).click();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await utils.checkNotification(page, 'Emergency access approved');
|
||||
await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved");
|
||||
});
|
||||
await activateTOTP(test, page, users.user1);
|
||||
|
||||
let newPassword = "TotoNewPassword";
|
||||
await test.step('Access', async () => {
|
||||
await emergencyAccess(test, page2, users.user2);
|
||||
await page2.getByRole('button', { name: 'Options' }).click();
|
||||
await page2.getByRole('menuitem', { name: 'Takeover' }).click();
|
||||
await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||
await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword);
|
||||
await page2.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page2, 'Password reset for ' + users.user1.name);
|
||||
});
|
||||
|
||||
await test.step('Changed no 2fa', async () => {
|
||||
users.user1.password = newPassword;
|
||||
await logUser(test, page, users.user1);
|
||||
});
|
||||
|
||||
await test.step('Reject', async () => {
|
||||
await emergencyAccess(test, page, users.user1);
|
||||
await page.getByRole('button', { name: 'Options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Reject' }).click();
|
||||
await utils.checkNotification(page, 'Emergency access rejected');
|
||||
await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected");
|
||||
});
|
||||
|
||||
await test.step('Remove', async () => {
|
||||
await page.getByRole('button', { name: 'Options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await utils.checkNotification(page, 'Removed user ' + users.user2.name);
|
||||
await expect(page.getByText('You have not added any emergency contacts')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
|
||||
test('Login', async ({ context, page }) => {
|
||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||
|
||||
await logUser(test, page, users.user1, mailBuffer);
|
||||
await logUser(test, page, users.user1, { mailBuffer });
|
||||
|
||||
await test.step('verify email', async () => {
|
||||
await page.getByText('Verify your account\'s email').click();
|
||||
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send email' }).click();
|
||||
|
||||
await page.getByRole('button', { name: "Send email" }).click();
|
||||
await utils.checkNotification(page, 'Check your email inbox for a verification link');
|
||||
|
||||
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
|
||||
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
|
||||
test('2fa', async ({ page }) => {
|
||||
const emails = mailserver.buffer(users.user1.email);
|
||||
|
||||
await test.step('login', async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByLabel('Master password').fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||
const code = await retrieveEmailCode(test, page, emails);
|
||||
await page.getByLabel(/Verification code/).fill(code);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Add it later' }).click();
|
||||
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||
|
||||
await expect(page).toHaveTitle(/Vaults/);
|
||||
})
|
||||
|
||||
await disableEmail(test, page, users.user1);
|
||||
await logUser(test, page, users.user1, {
|
||||
mailBuffer: emails,
|
||||
mail2fa: true,
|
||||
});
|
||||
|
||||
emails.close();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth";
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
import { createAccount, logUser } from './setups/user';
|
||||
import { activateTOTP, disableTOTP } from './setups/2fa';
|
||||
import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
let totp;
|
||||
@@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => {
|
||||
|
||||
await utils.logout(test, page, users.user1);
|
||||
|
||||
await test.step('login', async () => {
|
||||
let timestamp = Date.now(); // Needed to use the next token
|
||||
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
||||
|
||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByLabel('Master password').fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
});
|
||||
await logUser(test, page, users.user1, { totp });
|
||||
|
||||
await disableTOTP(test, page, users.user1);
|
||||
});
|
||||
|
||||
test('Recovery codes', async ({ context, page }) => {
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await activateTOTP(test, page, users.user1);
|
||||
let recovery = await recoveryCodes(test, page, users.user1);
|
||||
|
||||
await utils.logout(test, page, users.user1);
|
||||
|
||||
await test.step('login', async () => {
|
||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
|
||||
const newPagePromise = context.waitForEvent('page');
|
||||
await page.getByRole('button', { name: 'Use your recovery code' }).click();
|
||||
const newPage = await newPagePromise;
|
||||
|
||||
const tabs = context.pages();
|
||||
await tabs[1].bringToFront();
|
||||
await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible();
|
||||
await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email);
|
||||
await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
|
||||
await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery);
|
||||
await tabs[1].getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await expect(tabs[1]).toHaveTitle(/Two-step login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
|
||||
import * as utils from '../global-utils';
|
||||
import * as orgs from './setups/orgs';
|
||||
import { createAccount, logUser } from './setups/user';
|
||||
import { activateTOTP } from './setups/2fa';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
|
||||
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
await utils.startVault(browser, testInfo, {
|
||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||
SMTP_FROM: process.env.PW_SMTP_FROM,
|
||||
EMAIL_2FA_AUTO_FALLBACK: "true",
|
||||
});
|
||||
|
||||
mail1Buffer = mailServer.buffer(users.user1.email);
|
||||
@@ -40,6 +42,16 @@ test('Invite users', async ({ page }) => {
|
||||
await createAccount(test, page, users.user1, mail1Buffer);
|
||||
|
||||
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.invite(test, page, 'Test', users.user2.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 expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||
|
||||
//await page.getByLabel('Name').fill(users.user2.name);
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||
// await page.getByLabel('Name').fill(users.user2.name);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await utils.checkNotification(page, 'Your new account has been created');
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// Redirected to the vault
|
||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||
// await utils.checkNotification(page, 'You have been logged in!');
|
||||
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
@@ -90,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Unlock page
|
||||
await page.getByLabel('Master password').fill(users.user3.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||
|
||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||
});
|
||||
|
||||
test('Confirm invited user', async ({ page }) => {
|
||||
await logUser(test, page, users.user1, mail1Buffer);
|
||||
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||
|
||||
await orgs.members(test, page, 'Test');
|
||||
await orgs.confirm(test, page, 'Test', users.user2.email);
|
||||
@@ -113,7 +121,40 @@ test('Confirm invited user', 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 expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Recover user password', async ({ page }) => {
|
||||
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
|
||||
await activateTOTP(test, page, users.user2);
|
||||
|
||||
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
|
||||
|
||||
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('checkbox', { name: 'Reset two-step login' }).check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Account recovery success');
|
||||
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
|
||||
});
|
||||
|
||||
let user2 = {
|
||||
email: users.user2.email,
|
||||
name: users.user2.name,
|
||||
password: newPassword,
|
||||
};
|
||||
await logUser(test, page, user2, {
|
||||
mailBuffer: mail2Buffer,
|
||||
mail2fa: true,
|
||||
notNewDevice: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
import { createAccount, logUser } from './setups/user';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
let totp;
|
||||
|
||||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
await utils.startVault(browser, testInfo, {});
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await createAccount(test, page, users.user1);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async ({}) => {
|
||||
utils.stopVault();
|
||||
});
|
||||
|
||||
test('Password', async ({ context, page }, testInfo: TestInfo) => {
|
||||
const label = 'Test Password';
|
||||
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Create password entry', async () => {
|
||||
await page.getByRole('button', { name: 'New item' }).click();
|
||||
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
|
||||
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Item added');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Check', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||
await page.getByTestId('copy-username').click();
|
||||
await utils.checkNotification(page, 'Username copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
|
||||
await page.getByTestId('copy-password').click();
|
||||
await utils.checkNotification(page, 'Password copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
await test.step('Delete', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await utils.checkNotification(page, 'Item sent to bin');
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Deleted', async () => {
|
||||
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
|
||||
const label = 'Test SSH key';
|
||||
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
const privateKey = await test.step('Create key entry', async () => {
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'SSH key' }).click();
|
||||
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Item added');
|
||||
|
||||
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||
await utils.checkNotification(page, 'Private key copied');
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Check', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||
await utils.checkNotification(page, 'Private key copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
await test.step('Delete', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await utils.checkNotification(page, 'Item sent to bin');
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Deleted', async () => {
|
||||
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||
})
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth";
|
||||
|
||||
import * as utils from '../../global-utils';
|
||||
|
||||
export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string {
|
||||
return await test.step('Recovery code', async () => {
|
||||
await page.getByRole('button', { name: user.name }).click();
|
||||
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'View recovery code' }).click();
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
const recovery = await page.getByRole('code').innerText();
|
||||
await page.getByLabel('Close').click();
|
||||
|
||||
return recovery;
|
||||
})
|
||||
}
|
||||
|
||||
export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP {
|
||||
return await test.step('Activate TOTP 2FA', async () => {
|
||||
await page.getByRole('button', { name: user.name }).click();
|
||||
@@ -11,16 +29,16 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||
await page.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();
|
||||
|
||||
const secret = await page.getByLabel('Key').innerText();
|
||||
const secret = await page.getByLabel('Key', { exact: true }).innerText();
|
||||
|
||||
let totp = new OTPAuth.TOTP({ secret, period: 30 });
|
||||
|
||||
await page.getByLabel(/Verification code/).fill(totp.generate());
|
||||
await page.getByRole('button', { name: 'Turn on' }).click();
|
||||
await page.getByRole('heading', { name: 'Turned on', exact: true });
|
||||
await page.getByLabel('Close').click();
|
||||
|
||||
return totp;
|
||||
})
|
||||
@@ -33,8 +51,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
|
||||
await page.getByLabel('Master password (required)').click();
|
||||
await page.getByLabel('Master password (required)').fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Turn off' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
@@ -49,7 +67,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||
await page.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: 'Send email' }).click();
|
||||
});
|
||||
@@ -81,8 +99,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
|
||||
await page.getByLabel('Master password (required)').click();
|
||||
await page.getByLabel('Master password (required)').fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Turn off' }).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();
|
||||
|
||||
test('DB teardown ?', async ({ serviceName }) => {
|
||||
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
|
||||
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
|
||||
utils.stopComposeService(serviceName);
|
||||
}
|
||||
});
|
||||
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
|
||||
import * as utils from '../../global-utils';
|
||||
|
||||
export async function create(test, page: Page, name: string) {
|
||||
await test.step('Create Org', async () => {
|
||||
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
|
||||
await test.step(`Create Org ${name}`, async () => {
|
||||
let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
|
||||
if( await pm_locator.count() > 0 ){
|
||||
pm_locator.first().click();
|
||||
}
|
||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||
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 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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 page.getByRole('button', { name: 'Toggle collapse Settings' }).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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 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('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 expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||
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('combobox', { name: 'Permission' }).click();
|
||||
await page.getByText('Edit items', { exact: true }).click();
|
||||
await page.getByLabel('Select collections').click();
|
||||
await page.getByText('Default collection').click();
|
||||
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Select collections' }).click();
|
||||
await page.getByLabel('Options List').getByText('Default collection').click();
|
||||
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'User(s) invited');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
|
||||
utils.loadEnv();
|
||||
|
||||
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");
|
||||
} else {
|
||||
console.log("Keycloak stopping");
|
||||
|
||||
@@ -15,11 +15,8 @@ export async function logNewUser(
|
||||
options: { mailBuffer?: MailBuffer } = {}
|
||||
) {
|
||||
await test.step(`Create user ${user.name}`, async () => {
|
||||
await page.context().clearCookies();
|
||||
|
||||
await test.step('Landing page', async () => {
|
||||
await utils.cleanLanding(page);
|
||||
|
||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||
});
|
||||
@@ -33,26 +30,24 @@ export async function logNewUser(
|
||||
|
||||
await test.step('Create Vault account', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
});
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
|
||||
if( options.mailBuffer ){
|
||||
let mailBuffer = options.mailBuffer;
|
||||
await test.step('Check emails', async () => {
|
||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||
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,
|
||||
totp?: OTPAuth.TOTP,
|
||||
mail2fa?: boolean,
|
||||
notNewDevice?: boolean,
|
||||
} = {}
|
||||
) {
|
||||
let mailBuffer = options.mailBuffer;
|
||||
|
||||
await test.step(`Log user ${user.email}`, async () => {
|
||||
await page.context().clearCookies();
|
||||
|
||||
await test.step('Landing page', async () => {
|
||||
await utils.cleanLanding(page);
|
||||
|
||||
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||
});
|
||||
@@ -117,14 +110,12 @@ export async function logUser(
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
});
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
if( mailBuffer ){
|
||||
if( mailBuffer && !options.notNewDevice ){
|
||||
await test.step('Check email', async () => {
|
||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { expect, type Browser, Page } from '@playwright/test';
|
||||
|
||||
import { type MailBuffer } from 'maildev';
|
||||
|
||||
import * as OTPAuth from "otpauth";
|
||||
import * as utils from '../../global-utils';
|
||||
import { retrieveEmailCode } from './2fa';
|
||||
|
||||
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
|
||||
await test.step(`Create user ${user.name}`, async () => {
|
||||
@@ -17,12 +19,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Vault finish Creation
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Your new account has been created')
|
||||
await utils.ignoreExtension(page);
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||
@@ -35,7 +36,17 @@ 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,
|
||||
totp?: OTPAuth.TOTP,
|
||||
} = {}
|
||||
) {
|
||||
await test.step(`Log user ${user.email}`, async () => {
|
||||
await utils.cleanLanding(page);
|
||||
|
||||
@@ -43,16 +54,35 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Unlock page
|
||||
await page.getByLabel('Master password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
|
||||
await utils.ignoreExtension(page);
|
||||
if( options.mail2fa || options.totp ){
|
||||
let code;
|
||||
|
||||
await test.step('2FA check', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||
|
||||
if( options.totp ) {
|
||||
const totp = options.totp;
|
||||
let timestamp = Date.now(); // Needed to use the next token
|
||||
timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000;
|
||||
code = totp.generate({timestamp});
|
||||
} else if( options.mail2fa ){
|
||||
code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||
}
|
||||
|
||||
await page.getByLabel(/Verification code/).fill(code);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
});
|
||||
}
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
|
||||
if( mailBuffer ){
|
||||
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
|
||||
if( options.mailBuffer && !options.notNewDevice ){
|
||||
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 { MailDev } from 'maildev';
|
||||
|
||||
import * as admin from "./setups/admin";
|
||||
import { logNewUser, logUser } from './setups/sso';
|
||||
import { activateEmail, disableEmail } from './setups/2fa';
|
||||
import * as utils from "../global-utils";
|
||||
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
|
||||
await utils.startVault(browser, testInfo, {
|
||||
SSO_ENABLED: true,
|
||||
SSO_ONLY: false,
|
||||
SSO_ONLY: true,
|
||||
SMTP_HOST: process.env.MAILDEV_HOST,
|
||||
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);
|
||||
|
||||
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
|
||||
|
||||
await activateEmail(test, page, users.user1, mailBuffer);
|
||||
|
||||
mailBuffer.close();
|
||||
});
|
||||
|
||||
test('Log and disable', async ({ page }) => {
|
||||
const mailBuffer = mailserver.buffer(users.user1.email);
|
||||
|
||||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true});
|
||||
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
|
||||
|
||||
await disableEmail(test, page, users.user1);
|
||||
|
||||
mailBuffer.close();
|
||||
});
|
||||
|
||||
|
||||
test('Admin invite', async ({ page }) => {
|
||||
const mailBuffer = mailserver.buffer(users.user2.email);
|
||||
|
||||
await admin.login(test, page);
|
||||
await admin.invite(test, page, users.user2.email);
|
||||
|
||||
|
||||
const link = await test.step('Extract email link', async () => {
|
||||
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
|
||||
await page.setContent(invited.html);
|
||||
return await page.getByTestId("invite").getAttribute("href");
|
||||
});
|
||||
|
||||
await test.step('Redirect to Keycloak', async () => {
|
||||
await page.goto(link);
|
||||
});
|
||||
|
||||
await test.step('Keycloak login', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
});
|
||||
|
||||
await test.step('Create Vault account', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
|
||||
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
});
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
|
||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||
});
|
||||
|
||||
mailBuffer.close();
|
||||
});
|
||||
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Other' }).click();
|
||||
|
||||
// Unlock page
|
||||
await page.getByLabel('Master password').fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
|
||||
// We are now in the default vault page
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
||||
|
||||
// Landing page
|
||||
await page.goto('/');
|
||||
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||
|
||||
// Check that SSO login is available
|
||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
||||
@@ -66,7 +67,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
||||
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||
await utils.restartVault(page, testInfo, {
|
||||
SSO_ENABLED: false
|
||||
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
||||
|
||||
// Landing page
|
||||
await page.goto('/');
|
||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||
|
||||
// No SSO button (rely on a correct selector checked in previous test)
|
||||
await page.getByLabel('Master password');
|
||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
||||
|
||||
// Can continue to Master password
|
||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Log in 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 expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||
await page.getByRole('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 utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
});
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
|
||||
await utils.checkNotification(page, 'Account successfully created!');
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
|
||||
|
||||
await test.step('Redirect to Keycloak', async () => {
|
||||
await page.goto(link);
|
||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||
});
|
||||
|
||||
await test.step('Keycloak login', async () => {
|
||||
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||
await page.getByLabel('Master password').fill(users.user3.password);
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
|
||||
await utils.checkNotification(page, 'Invitation accepted');
|
||||
await utils.ignoreExtension(page);
|
||||
});
|
||||
|
||||
await test.step('Default vault page', async () => {
|
||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||
await utils.checkNotification(page, 'Successfully accepted your invitation');
|
||||
});
|
||||
|
||||
await test.step('Check mails', async () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
|
||||
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 orgs.policies(test, page, '/Test');
|
||||
|
||||
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Edited policy Master password requirements.');
|
||||
});
|
||||
|
||||
await utils.logout(test, page, users.user1);
|
||||
|
||||
await test.step(`Unlock trigger policy`, async () => {
|
||||
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||
await page.getByRole('button', { name: 'Use single sign-on' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('Unlock trigger policyy', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
|
||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||
|
||||
await test.step('Keycloak login', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||
await page.getByLabel(/Username/).fill(users.user2.name);
|
||||
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
});
|
||||
|
||||
await test.step('Unlock vault', async () => {
|
||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
|
||||
await page.getByLabel('Master password').fill(users.user2.password);
|
||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
|
||||
});
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
channel = "1.99.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
max_width = 120
|
||||
newline_style = "Unix"
|
||||
use_small_heuristics = "Off"
|
||||
+115
-88
@@ -2,40 +2,40 @@ use std::{env, sync::LazyLock};
|
||||
|
||||
use reqwest::Method;
|
||||
use rocket::{
|
||||
Catcher, Route,
|
||||
form::Form,
|
||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||
request::{FromRequest, Outcome, Request},
|
||||
response::{content::RawHtml as Html, Redirect},
|
||||
response::{Redirect, content::RawHtml as Html},
|
||||
serde::json::Json,
|
||||
Catcher, Route,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG, VERSION,
|
||||
api::{
|
||||
ApiResult, EmptyResult, JsonResult, Notify,
|
||||
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,
|
||||
db::{
|
||||
backup_sqlite, get_sql_server_version,
|
||||
ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
|
||||
models::{
|
||||
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
|
||||
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
|
||||
},
|
||||
DbConn, DbConnType, ACTIVE_DB_TYPE,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
sso::FAKE_SSO_IDENTIFIER,
|
||||
util::{
|
||||
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
|
||||
is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
|
||||
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
|
||||
get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
|
||||
},
|
||||
CONFIG, VERSION,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -93,8 +93,7 @@ static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
||||
});
|
||||
|
||||
#[cfg(sqlite)]
|
||||
static CAN_BACKUP: LazyLock<bool> =
|
||||
LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false));
|
||||
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
|
||||
#[cfg(not(sqlite))]
|
||||
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 !_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 validate_token(&data.token) {
|
||||
// If the token received is valid, generate JWT and save it as a cookie
|
||||
let claims = generate_admin_claims();
|
||||
let jwt = encode_jwt(&claims);
|
||||
@@ -224,15 +217,21 @@ fn post_admin_login(
|
||||
} else {
|
||||
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() {
|
||||
None => false,
|
||||
Some(t) if t.starts_with("$argon2") => {
|
||||
use argon2::password_hash::PasswordVerifier;
|
||||
match argon2::password_hash::PasswordHash::new(t) {
|
||||
match argon2::password_hash::phc::PasswordHash::new(t) {
|
||||
Ok(h) => {
|
||||
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
|
||||
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
|
||||
@@ -307,21 +306,14 @@ async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
|
||||
|
||||
#[post("/invite", format = "application/json", data = "<data>")]
|
||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||
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);
|
||||
|
||||
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_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
|
||||
} else {
|
||||
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))?;
|
||||
|
||||
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"),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -426,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
|
||||
|
||||
for membership in memberships {
|
||||
log_event(
|
||||
EventType::OrganizationUserDeleted as i32,
|
||||
EventType::OrganizationUserDeleted,
|
||||
&membership.uuid,
|
||||
&membership.org_uuid,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
@@ -447,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
|
||||
|
||||
for membership in memberships {
|
||||
log_event(
|
||||
EventType::OrganizationUserUnlinkedSso as i32,
|
||||
EventType::OrganizationUserUnlinkedSso,
|
||||
&membership.uuid,
|
||||
&membership.org_uuid,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
@@ -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 {
|
||||
Ok(r) => r,
|
||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +508,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
|
||||
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
||||
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
|
||||
user.totp_recover = None;
|
||||
user.save(&conn).await
|
||||
@@ -528,7 +528,7 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
||||
} else {
|
||||
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
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -554,9 +554,10 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
err!("The specified user isn't member of the organization")
|
||||
};
|
||||
|
||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid type"),
|
||||
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid type")
|
||||
};
|
||||
|
||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||
@@ -571,7 +572,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
||||
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserUpdated as i32,
|
||||
EventType::OrganizationUserUpdated,
|
||||
&member_to_edit.uuid,
|
||||
&data.org_uuid,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
@@ -643,11 +644,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
|
||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||
/// Any cache will be lost if Vaultwarden is restarted
|
||||
#[cached(time = 600, sync_writes = "default")]
|
||||
#[cached(ttl_secs = 600, sync_writes = "default")]
|
||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||
if has_http_access {
|
||||
@@ -656,42 +657,40 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||
.await
|
||||
{
|
||||
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 {
|
||||
Ok(mut c) => {
|
||||
c.sha.truncate(8);
|
||||
c.sha
|
||||
}
|
||||
_ => "-".to_string(),
|
||||
_ => "-".to_owned(),
|
||||
},
|
||||
// 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
|
||||
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
||||
_ => "-".to_string(),
|
||||
Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
|
||||
_ => "-".to_owned(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
("-".to_string(), "-".to_string(), "-".to_string())
|
||||
("-".to_owned(), "-".to_owned(), "-".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_ntp_time(has_http_access: bool) -> String {
|
||||
if has_http_access {
|
||||
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||
for line in cf_trace.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if key == "ts" {
|
||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||
for line in cf_trace.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& key == "ts"
|
||||
{
|
||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,6 +717,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")]
|
||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||
use chrono::prelude::*;
|
||||
@@ -734,7 +763,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
// Check if we are able to resolve DNS entries
|
||||
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
||||
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;
|
||||
@@ -745,7 +774,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
|
||||
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||
&CONFIG.experimental_client_feature_flags(),
|
||||
FeatureFlagFilter::InvalidOnly,
|
||||
&FeatureFlagFilter::InvalidOnly,
|
||||
)
|
||||
.into_keys()
|
||||
.collect();
|
||||
@@ -772,6 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
||||
"db_version": get_sql_server_version(&conn).await,
|
||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||
"overrides": &CONFIG.get_overrides().join(", "),
|
||||
"template_overrides": check_template_overrides().join(", "),
|
||||
"invalid_feature_flags": invalid_feature_flags,
|
||||
"host_arch": env::consts::ARCH,
|
||||
"host_os": env::consts::OS,
|
||||
@@ -834,33 +864,30 @@ impl<'r> FromRequest<'r> for AdminToken {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
|
||||
if !CONFIG.disable_admin_token() {
|
||||
let cookies = request.cookies();
|
||||
|
||||
let access_token = match cookies.get(COOKIE_NAME) {
|
||||
Some(cookie) => cookie.value(),
|
||||
None => {
|
||||
let requested_page =
|
||||
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
|
||||
// Else, return a 401 failure, which will be caught
|
||||
if requested_page.is_empty() {
|
||||
return Outcome::Forward(Status::Unauthorized);
|
||||
} else {
|
||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||
}
|
||||
let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
|
||||
cookie.value()
|
||||
} else {
|
||||
let requested_page =
|
||||
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
|
||||
// Else, return a 401 failure, which will be caught
|
||||
if requested_page.is_empty() {
|
||||
return Outcome::Forward(Status::Unauthorized);
|
||||
}
|
||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||
};
|
||||
|
||||
if decode_admin(access_token).is_err() {
|
||||
// Remove admin cookie
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -879,23 +906,23 @@ mod tests {
|
||||
fn validate_web_vault_compare() {
|
||||
// web_vault_compare(active, latest)
|
||||
// Test normal versions
|
||||
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
||||
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
|
||||
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
|
||||
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
|
||||
|
||||
// Test patched/+build.n versions
|
||||
// Newer latest version
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
||||
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
|
||||
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
|
||||
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
|
||||
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
|
||||
// Equal versions
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
||||
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
|
||||
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
|
||||
// Newer active version
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
||||
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
|
||||
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
|
||||
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
|
||||
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
|
||||
}
|
||||
}
|
||||
+470
-217
File diff suppressed because it is too large.
Load diff
+197
-157
@@ -2,33 +2,34 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use num_traits::ToPrimitive;
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::{
|
||||
form::{Form, FromForm},
|
||||
Route,
|
||||
form::{Form, FromForm},
|
||||
fs::TempFile,
|
||||
http::Status,
|
||||
serde::json::Json,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::ClientVersion;
|
||||
use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
|
||||
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},
|
||||
config::PathType,
|
||||
crypto,
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
|
||||
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
|
||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||
Membership, 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, sends::SIZE_525_MB};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
// Note that many routes have an `admin` variant; this seems to be
|
||||
@@ -108,7 +109,7 @@ pub async fn purge_trashed_ciphers(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Cipher::purge_trash(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging trashed ciphers")
|
||||
error!("Failed to get DB connection while purging trashed ciphers");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,13 +162,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
let policies_json: Vec<Value> =
|
||||
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
|
||||
|
||||
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(OrgPolicy::to_json)
|
||||
.collect();
|
||||
|
||||
let domains_json = if data.exclude_domains {
|
||||
Value::Null
|
||||
} else {
|
||||
api::core::_get_eq_domains(&headers, true).into_inner()
|
||||
api::core::get_eq_domains(&headers, true).into_inner()
|
||||
};
|
||||
|
||||
// This is very similar to the the userDecryptionOptions sent in connect/token,
|
||||
// This is very similar to the userDecryptionOptions sent in connect/token,
|
||||
// but as of 2025-12-19 they're both using different casing conventions.
|
||||
let has_master_password = !headers.user.password_hash.is_empty();
|
||||
let master_password_unlock = if has_master_password {
|
||||
@@ -193,11 +200,13 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
"folders": folders_json,
|
||||
"collections": collections_json,
|
||||
"policies": policies_json,
|
||||
"policiesNew": policies_new_json,
|
||||
"ciphers": ciphers_json,
|
||||
"domains": domains_json,
|
||||
"sends": sends_json,
|
||||
"userDecryption": {
|
||||
"masterPasswordUnlock": master_password_unlock,
|
||||
"userKeyId": headers.user.key_id,
|
||||
},
|
||||
"object": "sync"
|
||||
})))
|
||||
@@ -260,12 +269,19 @@ pub struct CipherData {
|
||||
|
||||
key: Option<String>,
|
||||
|
||||
pub encrypted_for: UserId, // Added in web-v2025.6.0
|
||||
// Added in web-v2025.8.1, Optional for compat
|
||||
pub encrypted_by_key_id: Option<KeyId>,
|
||||
|
||||
/*
|
||||
Login = 1,
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SshKey = 5
|
||||
BankAccount = 6
|
||||
DriversLicense = 7
|
||||
Passport = 8
|
||||
*/
|
||||
pub r#type: i32,
|
||||
pub name: String,
|
||||
@@ -278,6 +294,9 @@ pub struct CipherData {
|
||||
card: Option<Value>,
|
||||
identity: Option<Value>,
|
||||
ssh_key: Option<Value>,
|
||||
bank_account: Option<Value>,
|
||||
drivers_license: Option<Value>,
|
||||
passport: Option<Value>,
|
||||
|
||||
favorite: Option<bool>,
|
||||
reprompt: Option<i32>,
|
||||
@@ -333,6 +352,10 @@ async fn post_ciphers_create(
|
||||
) -> JsonResult {
|
||||
let mut data: ShareCipherData = data.into_inner();
|
||||
|
||||
if data.cipher.encrypted_for != headers.user.uuid {
|
||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||
}
|
||||
|
||||
// This check is usually only needed in update_cipher_from_data(), but we
|
||||
// need it here as well to avoid creating an empty cipher in the call to
|
||||
// cipher.save() below.
|
||||
@@ -362,6 +385,17 @@ async fn post_ciphers_create(
|
||||
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
let mut data: CipherData = data.into_inner();
|
||||
|
||||
if data.encrypted_for != headers.user.uuid {
|
||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
||||
}
|
||||
|
||||
if let Some(cipher_key_id) = &data.encrypted_by_key_id
|
||||
&& let Some(user_key_id) = &headers.user.key_id
|
||||
&& cipher_key_id != user_key_id
|
||||
{
|
||||
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
|
||||
}
|
||||
|
||||
// The web/browser clients set this field to null as expected, but the
|
||||
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
||||
// which results in a warning message being logged. This field isn't
|
||||
@@ -401,20 +435,34 @@ pub async fn update_cipher_from_data(
|
||||
nt: &Notify<'_>,
|
||||
ut: UpdateType,
|
||||
) -> 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 let Some(items) = json_data.as_array_mut() {
|
||||
for item in items.iter_mut().filter_map(Value::as_object_mut) {
|
||||
item.remove("response");
|
||||
}
|
||||
}
|
||||
json_data
|
||||
}
|
||||
|
||||
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||
|
||||
// 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.
|
||||
if ut != UpdateType::None {
|
||||
if let Some(dt) = data.last_known_revision_date {
|
||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
||||
// ISO 8601 format
|
||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||
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(_) => (),
|
||||
if ut != UpdateType::None
|
||||
&& let Some(dt) = data.last_known_revision_date
|
||||
{
|
||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
||||
// ISO 8601 format
|
||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||
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(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,9 +484,12 @@ pub async fn update_cipher_from_data(
|
||||
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
|
||||
None => err!("You don't have permission to add item to organization"),
|
||||
Some(member) => {
|
||||
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.
|
||||
// Write access to the cipher itself only counts when it already belongs to this organization.
|
||||
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|
||||
|| member.has_full_access()
|
||||
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|
||||
|| (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await)
|
||||
{
|
||||
cipher.organization_uuid = Some(org_id);
|
||||
// After some discussion in PR #1329 re-added the user_uuid = None again.
|
||||
@@ -456,25 +507,22 @@ pub async fn update_cipher_from_data(
|
||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||
}
|
||||
|
||||
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() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
if let Some(ref folder_id) = data.folder_id
|
||||
&& 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");
|
||||
}
|
||||
|
||||
// Modify attachments name and keys when rotating
|
||||
if let Some(attachments) = data.attachments2 {
|
||||
for (id, attachment) in attachments {
|
||||
let mut saved_att = match Attachment::find_by_id(&id, conn).await {
|
||||
Some(att) => att,
|
||||
None => {
|
||||
// Warn and continue here.
|
||||
// A missing attachment means it was removed via an other client.
|
||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||
// Bitwarden it self ignores these mismatches server side.
|
||||
warn!("Attachment {id} doesn't exist");
|
||||
continue;
|
||||
}
|
||||
let Some(mut saved_att) = Attachment::find_by_id(&id, conn).await else {
|
||||
// Warn and continue here.
|
||||
// A missing attachment means it was removed via an other client.
|
||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||
// Bitwarden it self ignores these mismatches server side.
|
||||
warn!("Attachment {id} doesn't exist");
|
||||
continue;
|
||||
};
|
||||
|
||||
if saved_att.cipher_uuid != cipher.uuid {
|
||||
@@ -491,46 +539,36 @@ 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 {
|
||||
1 => data.login,
|
||||
2 => data.secure_note,
|
||||
3 => data.card,
|
||||
4 => data.identity,
|
||||
5 => data.ssh_key,
|
||||
6 => data.bank_account,
|
||||
7 => data.drivers_license,
|
||||
8 => data.passport,
|
||||
_ => err!("Invalid type"),
|
||||
};
|
||||
|
||||
let type_data = match type_data_opt {
|
||||
Some(mut data) => {
|
||||
// Remove the 'Response' key from the base object.
|
||||
data.as_object_mut().unwrap().remove("response");
|
||||
// Remove the 'Response' key from every Uri.
|
||||
if data["uris"].is_array() {
|
||||
data["uris"] = _clean_cipher_data(data["uris"].clone());
|
||||
}
|
||||
data
|
||||
let type_data = if let Some(mut data) = type_data_opt {
|
||||
// Remove the 'Response' key from the base object.
|
||||
if let Some(data_obj) = data.as_object_mut() {
|
||||
data_obj.remove("response");
|
||||
}
|
||||
None => err!("Data missing"),
|
||||
// Remove the 'Response' key from every Uri.
|
||||
if data["uris"].is_array() {
|
||||
data["uris"] = clean_cipher_data(data["uris"].clone());
|
||||
}
|
||||
data
|
||||
} else {
|
||||
err!("Data missing")
|
||||
};
|
||||
|
||||
cipher.key = data.key;
|
||||
cipher.name = data.name;
|
||||
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.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);
|
||||
@@ -539,11 +577,12 @@ pub async fn update_cipher_from_data(
|
||||
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
||||
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
|
||||
|
||||
if let Some(dt_str) = data.archived_date {
|
||||
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||
match data.archived_date {
|
||||
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||
}
|
||||
},
|
||||
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||
}
|
||||
|
||||
if ut != UpdateType::None {
|
||||
@@ -555,16 +594,8 @@ pub async fn update_cipher_from_data(
|
||||
(_, _) => EventType::CipherUpdated,
|
||||
};
|
||||
|
||||
log_event(
|
||||
event_type as i32,
|
||||
&cipher.uuid,
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||
.await;
|
||||
}
|
||||
nt.send_cipher_update(
|
||||
ut,
|
||||
@@ -612,7 +643,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
||||
let existing_folders: HashSet<Option<FolderId>> =
|
||||
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());
|
||||
for folder in data.folders.into_iter() {
|
||||
for folder in data.folders {
|
||||
let folder_id = if existing_folders.contains(&folder.id) {
|
||||
folder.id.unwrap()
|
||||
} else {
|
||||
@@ -633,7 +664,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
||||
|
||||
// Read and create the ciphers
|
||||
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;
|
||||
|
||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
||||
@@ -737,10 +768,10 @@ async fn put_cipher_partial(
|
||||
err!("Cipher does not exist", "Cipher is not accessible for the current user")
|
||||
}
|
||||
|
||||
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() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
if let Some(ref folder_id) = data.folder_id
|
||||
&& 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");
|
||||
}
|
||||
|
||||
// Move cipher
|
||||
@@ -852,7 +883,7 @@ async fn post_collections_update(
|
||||
.await;
|
||||
|
||||
log_event(
|
||||
EventType::CipherUpdatedCollections as i32,
|
||||
EventType::CipherUpdatedCollections,
|
||||
&cipher.uuid,
|
||||
org_uuid,
|
||||
&headers.user.uuid,
|
||||
@@ -872,7 +903,7 @@ async fn put_collections_admin(
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
) -> JsonResult {
|
||||
post_collections_admin(cipher_id, data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
@@ -883,7 +914,7 @@ async fn post_collections_admin(
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
) -> JsonResult {
|
||||
let data: CollectionsAdminData = data.into_inner();
|
||||
|
||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||
@@ -932,7 +963,7 @@ async fn post_collections_admin(
|
||||
.await;
|
||||
|
||||
log_event(
|
||||
EventType::CipherUpdatedCollections as i32,
|
||||
EventType::CipherUpdatedCollections,
|
||||
&cipher.uuid,
|
||||
org_uuid,
|
||||
&headers.user.uuid,
|
||||
@@ -942,7 +973,7 @@ async fn post_collections_admin(
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1004,7 +1035,7 @@ async fn put_cipher_share_selected(
|
||||
err!("You must select at least one collection.")
|
||||
}
|
||||
|
||||
for cipher in data.ciphers.iter() {
|
||||
for cipher in &data.ciphers {
|
||||
if cipher.id.is_none() {
|
||||
err!("Request missing ids field")
|
||||
}
|
||||
@@ -1016,11 +1047,10 @@ async fn put_cipher_share_selected(
|
||||
collection_ids: data.collection_ids.clone(),
|
||||
};
|
||||
|
||||
match shared_cipher_data.cipher.id.take() {
|
||||
Some(id) => {
|
||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||
}
|
||||
None => err!("Request missing ids field"),
|
||||
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||
} else {
|
||||
err!("Request missing ids field")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1038,17 +1068,23 @@ async fn share_cipher_by_uuid(
|
||||
nt: &Notify<'_>,
|
||||
override_ut: Option<UpdateType>,
|
||||
) -> JsonResult {
|
||||
let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await {
|
||||
Some(cipher) => {
|
||||
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||
cipher
|
||||
} else {
|
||||
err!("Cipher is not write accessible")
|
||||
}
|
||||
let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await {
|
||||
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||
cipher
|
||||
} else {
|
||||
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![];
|
||||
|
||||
if let Some(organization_id) = &data.cipher.organization_id {
|
||||
@@ -1065,7 +1101,7 @@ async fn share_cipher_by_uuid(
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1149,6 +1185,10 @@ async fn post_attachment_v2(
|
||||
if file_size < 0 {
|
||||
err!("Attachment size can't be negative")
|
||||
}
|
||||
// Matches the "file" upload limit in main.rs, nothing larger can be uploaded
|
||||
if file_size > SIZE_525_MB {
|
||||
err!("Max file size is 525 MB.")
|
||||
}
|
||||
let attachment_id = crypto::generate_attachment_id();
|
||||
let attachment =
|
||||
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
||||
@@ -1263,10 +1303,10 @@ async fn save_attachment(
|
||||
err!("Cipher is neither owned by a user nor an organization");
|
||||
};
|
||||
|
||||
if let Some(size_limit) = size_limit {
|
||||
if size > size_limit {
|
||||
err!("Attachment storage limit exceeded with this file");
|
||||
}
|
||||
if let Some(size_limit) = size_limit
|
||||
&& size > size_limit
|
||||
{
|
||||
err!("Attachment storage limit exceeded with this file");
|
||||
}
|
||||
|
||||
let file_id = match &attachment {
|
||||
@@ -1332,7 +1372,7 @@ async fn save_attachment(
|
||||
|
||||
if let Some(org_id) = &cipher.organization_uuid {
|
||||
log_event(
|
||||
EventType::CipherAttachmentCreated as i32,
|
||||
EventType::CipherAttachmentCreated,
|
||||
&cipher.uuid,
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
@@ -1408,7 +1448,7 @@ async fn post_attachment_share(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
@@ -1442,7 +1482,7 @@ async fn delete_attachment(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> 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")]
|
||||
@@ -1453,42 +1493,42 @@ async fn delete_attachment_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> 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")]
|
||||
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
|
||||
}
|
||||
|
||||
#[post("/ciphers/<cipher_id>/delete-admin")]
|
||||
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
|
||||
}
|
||||
|
||||
#[put("/ciphers/<cipher_id>/delete")]
|
||||
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
|
||||
}
|
||||
|
||||
#[put("/ciphers/<cipher_id>/delete-admin")]
|
||||
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
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<cipher_id>")]
|
||||
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
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<cipher_id>/admin")]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1499,7 +1539,7 @@ async fn delete_cipher_selected(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
// permanent delete
|
||||
}
|
||||
|
||||
@@ -1510,7 +1550,7 @@ async fn delete_cipher_selected_post(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
// permanent delete
|
||||
}
|
||||
|
||||
@@ -1521,7 +1561,7 @@ async fn delete_cipher_selected_put(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||
// soft delete
|
||||
}
|
||||
|
||||
@@ -1532,7 +1572,7 @@ async fn delete_cipher_selected_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
// permanent delete
|
||||
}
|
||||
|
||||
@@ -1543,7 +1583,7 @@ async fn delete_cipher_selected_post_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await
|
||||
// permanent delete
|
||||
}
|
||||
|
||||
@@ -1554,18 +1594,18 @@ async fn delete_cipher_selected_put_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
_delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await
|
||||
// soft delete
|
||||
}
|
||||
|
||||
#[put("/ciphers/<cipher_id>/restore")]
|
||||
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")]
|
||||
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>")]
|
||||
@@ -1575,7 +1615,7 @@ async fn restore_cipher_selected_admin(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||
}
|
||||
|
||||
#[put("/ciphers/restore", data = "<data>")]
|
||||
@@ -1585,7 +1625,7 @@ async fn restore_cipher_selected(
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
_restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||
restore_multiple_ciphers(data, &headers, &conn, &nt).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1606,10 +1646,10 @@ async fn move_cipher_selected(
|
||||
let data = data.into_inner();
|
||||
let user_id = &headers.user.uuid;
|
||||
|
||||
if let Some(ref folder_id) = data.folder_id {
|
||||
if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
if let Some(ref folder_id) = data.folder_id
|
||||
&& 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");
|
||||
}
|
||||
|
||||
let cipher_count = data.ids.len();
|
||||
@@ -1693,7 +1733,7 @@ async fn purge_org_vault(
|
||||
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationPurgedVault as i32,
|
||||
EventType::OrganizationPurgedVault,
|
||||
&organization.org_id,
|
||||
&organization.org_id,
|
||||
&user.uuid,
|
||||
@@ -1773,7 +1813,7 @@ pub enum CipherDeleteOptions {
|
||||
HardMulti,
|
||||
}
|
||||
|
||||
async fn _delete_cipher_by_uuid(
|
||||
async fn delete_cipher_by_uuid(
|
||||
cipher_id: &CipherId,
|
||||
headers: &Headers,
|
||||
conn: &DbConn,
|
||||
@@ -1821,9 +1861,9 @@ async fn _delete_cipher_by_uuid(
|
||||
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|
||||
|| *delete_options == CipherDeleteOptions::SoftMulti
|
||||
{
|
||||
EventType::CipherSoftDeleted as i32
|
||||
EventType::CipherSoftDeleted
|
||||
} else {
|
||||
EventType::CipherDeleted as i32
|
||||
EventType::CipherDeleted
|
||||
};
|
||||
|
||||
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
|
||||
@@ -1836,10 +1876,10 @@ async fn _delete_cipher_by_uuid(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CipherIdsData {
|
||||
ids: Vec<CipherId>,
|
||||
ids: HashSet<CipherId>,
|
||||
}
|
||||
|
||||
async fn _delete_multiple_ciphers(
|
||||
async fn delete_multiple_ciphers(
|
||||
data: Json<CipherIdsData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
@@ -1849,9 +1889,9 @@ async fn _delete_multiple_ciphers(
|
||||
let data = data.into_inner();
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here
|
||||
@@ -1860,7 +1900,7 @@ async fn _delete_multiple_ciphers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn _restore_cipher_by_uuid(
|
||||
async fn restore_cipher_by_uuid(
|
||||
cipher_id: &CipherId,
|
||||
headers: &Headers,
|
||||
multi_restore: bool,
|
||||
@@ -1892,7 +1932,7 @@ async fn _restore_cipher_by_uuid(
|
||||
|
||||
if let Some(org_id) = &cipher.organization_uuid {
|
||||
log_event(
|
||||
EventType::CipherRestored as i32,
|
||||
EventType::CipherRestored,
|
||||
&cipher.uuid.clone(),
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
@@ -1906,7 +1946,7 @@ async fn _restore_cipher_by_uuid(
|
||||
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>,
|
||||
headers: &Headers,
|
||||
conn: &DbConn,
|
||||
@@ -1916,7 +1956,7 @@ async fn _restore_multiple_ciphers(
|
||||
|
||||
let mut ciphers: Vec<Value> = Vec::new();
|
||||
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()),
|
||||
err => return err,
|
||||
}
|
||||
@@ -1932,7 +1972,7 @@ async fn _restore_multiple_ciphers(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn _delete_cipher_attachment_by_id(
|
||||
async fn delete_cipher_attachment_by_id(
|
||||
cipher_id: &CipherId,
|
||||
attachment_id: &AttachmentId,
|
||||
headers: &Headers,
|
||||
@@ -1969,7 +2009,7 @@ async fn _delete_cipher_attachment_by_id(
|
||||
|
||||
if let Some(ref org_id) = cipher.organization_uuid {
|
||||
log_event(
|
||||
EventType::CipherAttachmentDeleted as i32,
|
||||
EventType::CipherAttachmentDeleted,
|
||||
&cipher.uuid,
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
@@ -2141,14 +2181,21 @@ impl CipherSyncData {
|
||||
// Organization Sync does not support Folders, Favorites, or Archives.
|
||||
// If these are set, it will cause issues in the web-vault.
|
||||
CipherSyncType::Organization => {
|
||||
cipher_folders = HashMap::with_capacity(0);
|
||||
cipher_favorites = HashSet::with_capacity(0);
|
||||
cipher_archives = HashMap::with_capacity(0);
|
||||
cipher_folders = HashMap::new();
|
||||
cipher_favorites = HashSet::new();
|
||||
cipher_archives = HashMap::new();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|m| (m.org_uuid.clone(), m))
|
||||
.collect();
|
||||
|
||||
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
|
||||
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
|
||||
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
|
||||
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
|
||||
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
|
||||
for attachment in attachments {
|
||||
@@ -2163,13 +2210,6 @@ impl CipherSyncData {
|
||||
cipher_collections.entry(cipher).or_default().push(collection);
|
||||
}
|
||||
|
||||
// Generate a HashMap with the Organization UUID as key and the Membership record
|
||||
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|m| (m.org_uuid.clone(), m))
|
||||
.collect();
|
||||
|
||||
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
|
||||
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
|
||||
.await
|
||||
@@ -2206,11 +2246,11 @@ impl CipherSyncData {
|
||||
};
|
||||
|
||||
Self {
|
||||
cipher_archives,
|
||||
cipher_attachments,
|
||||
cipher_folders,
|
||||
cipher_favorites,
|
||||
cipher_collections,
|
||||
cipher_archives,
|
||||
members,
|
||||
user_collections,
|
||||
user_collections_groups,
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use rocket::{serde::json::Json, Route};
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{CipherSyncData, CipherSyncType},
|
||||
EmptyResult, JsonResult,
|
||||
core::{
|
||||
CipherSyncData, CipherSyncType,
|
||||
accounts::{AuthenticationData, UnlockData},
|
||||
},
|
||||
},
|
||||
auth::{decode_emergency_access_invite, Headers},
|
||||
auth::{Headers, decode_emergency_access_invite},
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -55,7 +58,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||
for ea in emergency_access_list {
|
||||
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 +92,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await {
|
||||
Some(emergency_access) => Ok(Json(
|
||||
if let Some(emergency_access) =
|
||||
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!"),
|
||||
)),
|
||||
None => err!("Emergency access not valid."),
|
||||
))
|
||||
} else {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,9 +142,10 @@ async fn post_emergency_access(
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
emergency_access.atype = new_type;
|
||||
@@ -205,9 +212,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
||||
|
||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid emergency access type."),
|
||||
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
new_type as i32
|
||||
} else {
|
||||
err!("Invalid emergency access type.")
|
||||
};
|
||||
|
||||
let grantor_user = headers.user;
|
||||
@@ -220,7 +228,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
||||
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
|
||||
None => {
|
||||
if !CONFIG.invitations_allowed() {
|
||||
err!(format!("Grantee user does not exist: {email}"))
|
||||
err!(format!("Grantee user does not exist: {}", email.escape_debug()))
|
||||
}
|
||||
|
||||
if !CONFIG.is_email_domain_allowed(&email) {
|
||||
@@ -342,12 +350,11 @@ async fn accept_invite(
|
||||
err!("Claim email does not match current users email")
|
||||
}
|
||||
|
||||
let grantee_user = match User::find_by_mail(&claims.email, &conn).await {
|
||||
Some(user) => {
|
||||
Invitation::take(&claims.email, &conn).await;
|
||||
user
|
||||
}
|
||||
None => err!("Invited user not found"),
|
||||
let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
|
||||
Invitation::take(&claims.email, &conn).await;
|
||||
user
|
||||
} else {
|
||||
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.
|
||||
@@ -611,6 +618,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
||||
"kdfMemory": grantor_user.client_kdf_memory,
|
||||
"kdfParallelism": grantor_user.client_kdf_parallelism,
|
||||
"keyEncrypted": &emergency_access.key_encrypted,
|
||||
"salt": grantor_user.master_password_salt(),
|
||||
"object": "emergencyAccessTakeover",
|
||||
});
|
||||
|
||||
@@ -620,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EmergencyAccessPasswordData {
|
||||
new_master_password_hash: String,
|
||||
key: String,
|
||||
// Legacy payload
|
||||
new_master_password_hash: Option<String>,
|
||||
key: Option<String>,
|
||||
|
||||
// Current payload
|
||||
authentication_data: Option<AuthenticationData>,
|
||||
unlock_data: Option<UnlockData>,
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
||||
@@ -634,8 +647,6 @@ async fn password_emergency_access(
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let data: EmergencyAccessPasswordData = data.into_inner();
|
||||
let new_master_password_hash = &data.new_master_password_hash;
|
||||
//let key = &data.Key;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let Some(emergency_access) =
|
||||
@@ -652,8 +663,23 @@ async fn password_emergency_access(
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
let (new_master_password_hash, new_key) =
|
||||
if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) {
|
||||
authentication_data.check(&grantor_user, &unlock_data)?;
|
||||
|
||||
if !authentication_data.kdf.matches_user(&grantor_user) {
|
||||
err!("KDF settings do not match the grantor account")
|
||||
}
|
||||
|
||||
(authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key)
|
||||
} else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) {
|
||||
(new_master_password_hash, new_key)
|
||||
} else {
|
||||
err!("Invalid request!")
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?;
|
||||
grantor_user.save(&conn).await?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
@@ -766,7 +792,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
||||
}
|
||||
}
|
||||
} 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 +851,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
||||
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||
}
|
||||
}
|
||||
+97
-80
@@ -1,18 +1,19 @@
|
||||
use std::net::IpAddr;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use rocket::{form::FromForm, serde::json::Json, Route};
|
||||
use rocket::{Route, form::FromForm, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult},
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, JsonResult},
|
||||
auth::{AdminHeaders, Headers},
|
||||
db::{
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
DbConn, DbPool,
|
||||
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
|
||||
},
|
||||
error::MapResult,
|
||||
util::parse_date,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
/// ###############################################################################################################
|
||||
@@ -29,6 +30,14 @@ struct EventRange {
|
||||
continuation_token: Option<String>,
|
||||
}
|
||||
|
||||
impl EventRange {
|
||||
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
|
||||
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
|
||||
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
|
||||
Ok((start_date, end_date))
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
||||
#[get("/organizations/<org_id>/events?<data..>")]
|
||||
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
||||
@@ -38,21 +47,16 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let (start_date, end_date) = data.date_range()?;
|
||||
|
||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -64,27 +68,16 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||
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
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut events_json = Vec::with_capacity(0);
|
||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
||||
{
|
||||
let (start_date, end_date) = data.date_range()?;
|
||||
|
||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.collect()
|
||||
}
|
||||
events_json
|
||||
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -107,21 +100,16 @@ async fn get_user_events(
|
||||
}
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let (start_date, end_date) = data.date_range()?;
|
||||
|
||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -134,7 +122,8 @@ async fn get_user_events(
|
||||
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 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() {
|
||||
last_event["date"].as_str()
|
||||
} else {
|
||||
@@ -173,10 +162,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
}
|
||||
|
||||
for event in data.iter() {
|
||||
let event_date = parse_date(&event.date);
|
||||
let event_date = parse_date(&event.date).map_res("Invalid event date")?;
|
||||
// Only the event types upstream accepts from clients, the rest are written server-side.
|
||||
match event.r#type {
|
||||
1000..=1099 => {
|
||||
_log_user_event(
|
||||
t if t == EventType::UserClientExportedVault as i32 => {
|
||||
log_user_event_impl(
|
||||
event.r#type,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
@@ -186,9 +176,12 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
1600..=1699 => {
|
||||
if let Some(org_id) = &event.organization_id {
|
||||
_log_event(
|
||||
t if t == EventType::OrganizationClientExportedVault as i32 => {
|
||||
// Only allow logging events for an organization the user is actually a member of.
|
||||
if let Some(org_id) = &event.organization_id
|
||||
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
||||
{
|
||||
log_event_impl(
|
||||
event.r#type,
|
||||
org_id,
|
||||
org_id,
|
||||
@@ -201,38 +194,62 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(cipher_uuid) = &event.cipher_id {
|
||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await {
|
||||
if let Some(org_id) = cipher.organization_uuid {
|
||||
_log_event(
|
||||
event.r#type,
|
||||
cipher_uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// Only the vault notification banner click is accepted from clients. The rest of
|
||||
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
|
||||
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
|
||||
if let Some(org_id) = &event.organization_id
|
||||
&& let Some(membership) =
|
||||
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
|
||||
{
|
||||
log_event_impl(
|
||||
event.r#type,
|
||||
&membership.uuid,
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
||||
1107..=1114 | 1117 | 1119..=1132 => {
|
||||
// The cipher determines the organization the event is logged to, so make sure the
|
||||
// user can actually access it instead of trusting the provided cipher uuid.
|
||||
if let Some(cipher_uuid) = &event.cipher_id
|
||||
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||
&& let Some(org_id) = cipher.organization_uuid
|
||||
{
|
||||
log_event_impl(
|
||||
event.r#type,
|
||||
cipher_uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
&headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
||||
pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
||||
log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
async fn _log_user_event(
|
||||
async fn log_user_event_impl(
|
||||
event_type: i32,
|
||||
user_id: &UserId,
|
||||
device_type: i32,
|
||||
@@ -267,7 +284,7 @@ async fn _log_user_event(
|
||||
}
|
||||
|
||||
pub async fn log_event(
|
||||
event_type: i32,
|
||||
event_type: EventType,
|
||||
source_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
act_user_id: &UserId,
|
||||
@@ -278,11 +295,11 @@ pub async fn log_event(
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn _log_event(
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn log_event_impl(
|
||||
event_type: i32,
|
||||
source_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
@@ -298,24 +315,24 @@ async fn _log_event(
|
||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||
// Cipher Events
|
||||
1100..=1199 => {
|
||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
||||
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Collection Events
|
||||
1300..=1399 => {
|
||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
||||
event.collection_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Group Events
|
||||
1400..=1499 => {
|
||||
event.group_uuid = Some(source_uuid.to_string().into());
|
||||
event.group_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Org User Events
|
||||
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
|
||||
// Policy Events
|
||||
1700..=1799 => {
|
||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
||||
event.policy_uuid = Some(source_uuid.to_owned().into());
|
||||
}
|
||||
// Ignore others
|
||||
_ => {}
|
||||
@@ -338,6 +355,6 @@ pub async fn event_cleanup_job(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Event::clean_events(&conn).await.ok();
|
||||
} else {
|
||||
error!("Failed to get DB connection while trying to cleanup the events table")
|
||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{Folder, FolderId},
|
||||
DbConn,
|
||||
models::{Folder, FolderId},
|
||||
},
|
||||
util::deser_opt_nonempty_str,
|
||||
};
|
||||
@@ -29,9 +29,10 @@ async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/folders/<folder_id>")]
|
||||
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 {
|
||||
Some(folder) => Ok(Json(folder.to_json())),
|
||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
||||
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||
Ok(Json(folder.to_json()))
|
||||
} else {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-40
@@ -1,4 +1,6 @@
|
||||
pub mod accounts;
|
||||
pub mod two_factor;
|
||||
|
||||
mod ciphers;
|
||||
mod emergency_access;
|
||||
mod events;
|
||||
@@ -6,17 +8,32 @@ mod folders;
|
||||
mod organizations;
|
||||
mod public;
|
||||
mod sends;
|
||||
pub mod two_factor;
|
||||
|
||||
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 events::{event_cleanup_job, log_event, log_user_event};
|
||||
use reqwest::Method;
|
||||
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> {
|
||||
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 meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||
|
||||
@@ -44,25 +61,6 @@ pub fn events_routes() -> Vec<Route> {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GlobalDomain {
|
||||
@@ -73,15 +71,17 @@ struct GlobalDomain {
|
||||
|
||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||
|
||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||
#[get("/settings/domains")]
|
||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
||||
_get_eq_domains(&headers, false)
|
||||
fn get_settings_domains(headers: Headers) -> Json<Value> {
|
||||
get_eq_domains(&headers, false)
|
||||
}
|
||||
|
||||
fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||
let user = &headers.user;
|
||||
fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||
use serde_json::from_str;
|
||||
|
||||
let user = &headers.user;
|
||||
|
||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
||||
|
||||
@@ -110,17 +110,23 @@ struct EquivDomainData {
|
||||
}
|
||||
|
||||
#[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 excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
||||
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
||||
|
||||
let mut user = headers.user;
|
||||
use serde_json::to_string;
|
||||
|
||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
|
||||
user.equivalent_domains = to_string(&equivalent_domains).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_owned());
|
||||
|
||||
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>")]
|
||||
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
post_eq_domains(data, headers, conn, nt).await
|
||||
async fn put_settings_domains(
|
||||
data: Json<EquivDomainData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
post_settings_domains(data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
#[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
|
||||
let mut feature_states = parse_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!({
|
||||
// Note: The clients use this version to handle backwards compatibility concerns
|
||||
@@ -217,14 +228,17 @@ fn config() -> Json<Value> {
|
||||
// Version history:
|
||||
// - Individual cipher key encryption: 2024.2.0
|
||||
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||
"version": "2025.12.0",
|
||||
"version": "2026.6.0",
|
||||
"gitHash": option_env!("GIT_REV"),
|
||||
"server": {
|
||||
"name": "Vaultwarden",
|
||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||
},
|
||||
"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": {
|
||||
"vault": domain,
|
||||
@@ -240,6 +254,10 @@ fn config() -> Json<Value> {
|
||||
"vapidPublicKey": null
|
||||
},
|
||||
"featureStates": feature_states,
|
||||
// Not supported right now
|
||||
// Used for by clients to learn if the server requires extra work to establish a connection.
|
||||
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
|
||||
"communication": null,
|
||||
"object": "config",
|
||||
}))
|
||||
}
|
||||
@@ -278,9 +296,8 @@ async fn accept_org_invite(
|
||||
member.save(conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
||||
Some(org) => org,
|
||||
None => err!("Organization not found."),
|
||||
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
|
||||
err!("Organization not found.")
|
||||
};
|
||||
// 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)
|
||||
|
||||
+375
-415
File diff suppressed because it is too large.
Load diff
+72
-82
@@ -1,23 +1,24 @@
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
Request, Route,
|
||||
};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
Request, Route,
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
|
||||
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
|
||||
OrganizationApiKey, OrganizationId, User,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -57,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
let org_id = token.0;
|
||||
let data = data.into_inner();
|
||||
|
||||
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
|
||||
for user_data in &data.members {
|
||||
let mut user_created: bool = false;
|
||||
if user_data.deleted {
|
||||
// If user is marked for deletion and it exists, revoke it
|
||||
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||
// Only revoke a user if it is not the last confirmed owner
|
||||
let revoked = if member.atype == MembershipType::Owner
|
||||
&& member.status == MembershipStatus::Confirmed as i32
|
||||
{
|
||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||
warn!("Can't revoke the last owner");
|
||||
false
|
||||
} else {
|
||||
member.revoke()
|
||||
}
|
||||
} else {
|
||||
member.revoke()
|
||||
};
|
||||
|
||||
let revoked = member.atype != MembershipType::Owner && member.revoke();
|
||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||
if revoked || ext_modified {
|
||||
member.save(&conn).await?;
|
||||
@@ -83,26 +72,32 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
}
|
||||
// If user is part of the organization, restore it
|
||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||
let restored = member.restore();
|
||||
let mut restored = member.atype != MembershipType::Owner && member.restore();
|
||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||
// Enforce org policies as every other restore path does.
|
||||
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
|
||||
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 {
|
||||
member.save(&conn).await?;
|
||||
}
|
||||
} else {
|
||||
// If user is not part of the organization
|
||||
let user = match User::find_by_mail(&user_data.email, &conn).await {
|
||||
Some(user) => user, // exists in vaultwarden
|
||||
None => {
|
||||
// User does not exist yet
|
||||
let mut new_user = User::new(&user_data.email, None);
|
||||
new_user.save(&conn).await?;
|
||||
let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
|
||||
user
|
||||
} else {
|
||||
// User does not exist yet
|
||||
let mut new_user = User::new(&user_data.email, None);
|
||||
new_user.save(&conn).await?;
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
Invitation::new(&new_user.email).save(&conn).await?;
|
||||
}
|
||||
user_created = true;
|
||||
new_user
|
||||
if !CONFIG.mail_enabled() {
|
||||
Invitation::new(&new_user.email).save(&conn).await?;
|
||||
}
|
||||
user_created = true;
|
||||
new_user
|
||||
};
|
||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||
MembershipStatus::Invited as i32
|
||||
@@ -110,9 +105,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
|
||||
};
|
||||
|
||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await {
|
||||
Some(org) => (org.name, org.billing_email),
|
||||
None => err!("Error looking up organization"),
|
||||
let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||
(org.name, org.billing_email)
|
||||
} else {
|
||||
err!("Error looking up organization")
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||
@@ -123,37 +119,37 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
|
||||
new_member.save(&conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Err(e) =
|
||||
if CONFIG.mail_enabled()
|
||||
&& let Err(e) =
|
||||
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
|
||||
if user_created {
|
||||
user.delete(&conn).await?;
|
||||
} else {
|
||||
new_member.delete(&conn).await?;
|
||||
}
|
||||
|
||||
err!(format!("Error sending invite: {e:?} "));
|
||||
{
|
||||
// Upon error delete the user, invite and org member records when needed
|
||||
if user_created {
|
||||
user.delete(&conn).await?;
|
||||
} else {
|
||||
new_member.delete(&conn).await?;
|
||||
}
|
||||
|
||||
err!(format!("Error sending invite: {e:?} "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG.org_groups_enabled() {
|
||||
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 {
|
||||
Some(group) => group.uuid,
|
||||
None => {
|
||||
let mut group = Group::new(
|
||||
org_id.clone(),
|
||||
group_data.name.clone(),
|
||||
false,
|
||||
Some(group_data.external_id.clone()),
|
||||
);
|
||||
let group_uuid = if let Some(mut group) =
|
||||
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||
{
|
||||
if group.name != group_data.name {
|
||||
group.name = group_data.name.clone();
|
||||
group.save(&conn).await?;
|
||||
group.uuid
|
||||
}
|
||||
group.uuid
|
||||
} else {
|
||||
let mut group =
|
||||
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
|
||||
group.save(&conn).await?;
|
||||
group.uuid
|
||||
};
|
||||
|
||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||
@@ -174,18 +170,11 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
// 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();
|
||||
for member in Membership::find_by_org(&org_id, &conn).await {
|
||||
if let Some(ref user_external_id) = member.external_id {
|
||||
if !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 Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
|
||||
{
|
||||
warn!("Can't delete the last owner");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
member.delete(&conn).await?;
|
||||
}
|
||||
if let Some(ref user_external_id) = member.external_id
|
||||
&& !sync_members.contains(user_external_id)
|
||||
&& member.atype != MembershipType::Owner
|
||||
{
|
||||
member.delete(&conn).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,12 +191,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
// Get access_token
|
||||
let access_token: &str = match headers.get_one("Authorization") {
|
||||
Some(a) => match a.rsplit("Bearer ").next() {
|
||||
Some(split) => split,
|
||||
None => err_handler!("No access token provided"),
|
||||
},
|
||||
None => err_handler!("No access token provided"),
|
||||
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 device and user from it
|
||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||
@@ -229,14 +220,13 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
|
||||
// Check if claims.sub is org_api_key.uuid
|
||||
// Check if claims.client_sub is org_api_key.org_uuid
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
||||
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 {
|
||||
err_handler!("Invalid client_id")
|
||||
};
|
||||
|
||||
+64
-186
@@ -10,20 +10,20 @@ use rocket::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::{ClientIp, Headers, Host},
|
||||
auth::{Headers, Host, SendHeaders},
|
||||
config::PathType,
|
||||
db::{
|
||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||
DbConn, DbPool,
|
||||
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||
},
|
||||
util::{save_temp_file, NumberOrString},
|
||||
CONFIG,
|
||||
util::{NumberOrString, save_temp_file},
|
||||
};
|
||||
|
||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
|
||||
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
|
||||
let dt = DateTime::UNIX_EPOCH.naive_utc();
|
||||
Device {
|
||||
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
|
||||
created_at: dt,
|
||||
@@ -39,14 +39,13 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
|
||||
const SIZE_525_MB: i64 = 550_502_400;
|
||||
pub(crate) const SIZE_525_MB: i64 = 550_502_400;
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
get_sends,
|
||||
get_send,
|
||||
post_send,
|
||||
post_send_file,
|
||||
post_access,
|
||||
post_access_file,
|
||||
put_send,
|
||||
@@ -63,7 +62,7 @@ pub async fn purge_sends(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
Send::purge(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging sends")
|
||||
error!("Failed to get DB connection while purging sends");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,13 +70,14 @@ pub async fn purge_sends(pool: DbPool) {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendData {
|
||||
r#type: i32,
|
||||
key: String,
|
||||
pub key: String,
|
||||
password: Option<String>,
|
||||
max_access_count: Option<NumberOrString>,
|
||||
expiration_date: Option<DateTime<Utc>>,
|
||||
deletion_date: DateTime<Utc>,
|
||||
disabled: bool,
|
||||
hide_email: Option<bool>,
|
||||
emails: Option<String>,
|
||||
|
||||
// Data field
|
||||
name: String,
|
||||
@@ -148,6 +148,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());
|
||||
send.user_uuid = Some(user_id);
|
||||
send.notes = data.notes;
|
||||
@@ -168,7 +172,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
||||
#[get("/sends")]
|
||||
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
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!({
|
||||
"data": sends_json,
|
||||
@@ -179,9 +183,10 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/sends/<send_id>")]
|
||||
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 {
|
||||
Some(send) => Ok(Json(send.to_json())),
|
||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
||||
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||
Ok(Json(send.to_json()))
|
||||
} else {
|
||||
err!("Send not found", "Invalid send uuid or does not belong to user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +198,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
|
||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||
|
||||
if data.r#type == SendType::File as i32 {
|
||||
err!("File sends should use /api/sends/file")
|
||||
err!("File sends should use /api/sends/file/v2")
|
||||
}
|
||||
|
||||
let mut send = create_send(data, headers.user.uuid)?;
|
||||
@@ -210,93 +215,11 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
|
||||
Ok(Json(send.to_json()))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct UploadData<'f> {
|
||||
model: Json<SendData>,
|
||||
data: TempFile<'f>,
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct UploadDataV2<'f> {
|
||||
data: TempFile<'f>,
|
||||
}
|
||||
|
||||
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
|
||||
// This method still exists to support older clients, probably need to remove it sometime.
|
||||
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
|
||||
// 2025: This endpoint doesn't seem to exists anymore in the latest version
|
||||
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
|
||||
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
|
||||
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
enforce_disable_send_policy(&headers, &conn).await?;
|
||||
|
||||
let UploadData {
|
||||
model,
|
||||
data,
|
||||
} = data.into_inner();
|
||||
let model = model.into_inner();
|
||||
|
||||
let Some(size) = data.len().to_i64() else {
|
||||
err!("Invalid send size");
|
||||
};
|
||||
if size < 0 {
|
||||
err!("Send size can't be negative")
|
||||
}
|
||||
|
||||
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
|
||||
|
||||
let size_limit = match CONFIG.user_send_limit() {
|
||||
Some(0) => err!("File uploads are disabled"),
|
||||
Some(limit_kb) => {
|
||||
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
|
||||
err!("Existing sends overflow")
|
||||
};
|
||||
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
|
||||
err!("Send size overflow");
|
||||
};
|
||||
if left <= 0 {
|
||||
err!("Send storage limit reached! Delete some sends to free up space")
|
||||
}
|
||||
i64::clamp(left, 0, SIZE_525_MB)
|
||||
}
|
||||
None => SIZE_525_MB,
|
||||
};
|
||||
|
||||
if size > size_limit {
|
||||
err!("Send storage limit exceeded with this file");
|
||||
}
|
||||
|
||||
let mut send = create_send(model, headers.user.uuid)?;
|
||||
if send.atype != SendType::File as i32 {
|
||||
err!("Send content is not a file");
|
||||
}
|
||||
|
||||
let file_id = crate::crypto::generate_send_file_id();
|
||||
|
||||
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
|
||||
|
||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||
if let Some(o) = data_value.as_object_mut() {
|
||||
o.insert(String::from("id"), Value::String(file_id));
|
||||
o.insert(String::from("size"), Value::Number(size.into()));
|
||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
|
||||
}
|
||||
send.data = serde_json::to_string(&data_value)?;
|
||||
|
||||
// Save the changes in the database
|
||||
send.save(&conn).await?;
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendCreate,
|
||||
&send,
|
||||
&send.update_users_revision(&conn).await,
|
||||
&headers.device,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(send.to_json()))
|
||||
}
|
||||
|
||||
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
|
||||
#[post("/sends/file/v2", data = "<data>")]
|
||||
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
@@ -310,9 +233,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
||||
|
||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||
|
||||
let file_length = match &data.file_length {
|
||||
Some(m) => m.into_i64()?,
|
||||
_ => err!("Invalid send length"),
|
||||
let file_length = if let Some(m) = &data.file_length {
|
||||
m.into_i64()?
|
||||
} else {
|
||||
err!("Invalid send length")
|
||||
};
|
||||
if file_length < 0 {
|
||||
err!("Send size can't be negative")
|
||||
@@ -369,7 +293,7 @@ pub struct SendFileData {
|
||||
}
|
||||
|
||||
// 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(
|
||||
send_id: SendId,
|
||||
file_id: SendFileId,
|
||||
@@ -404,8 +328,8 @@ async fn post_send_file_v2_data(
|
||||
"Send file name does not match.",
|
||||
format!(
|
||||
"Expected file name '{}' got '{}'",
|
||||
send_data.fileName,
|
||||
raw_file_name.dangerous_unsafe_unsanitized_raw()
|
||||
send_data.fileName.escape_debug(),
|
||||
raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
|
||||
)
|
||||
),
|
||||
_ => err!("Send file name does not match or is not provided."),
|
||||
@@ -439,59 +363,22 @@ async fn post_send_file_v2_data(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendAccessData {
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/sends/access/<access_id>", data = "<data>")]
|
||||
async fn post_access(
|
||||
access_id: &str,
|
||||
data: Json<SendAccessData>,
|
||||
conn: DbConn,
|
||||
ip: ClientIp,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
||||
#[post("/sends/access")]
|
||||
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
if send.access_count >= max_access_count {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(expiration) = send.expiration_date {
|
||||
if Utc::now().naive_utc() >= expiration {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
}
|
||||
|
||||
if Utc::now().naive_utc() >= send.deletion_date {
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if send.disabled {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if send.password_hash.is_some() {
|
||||
match data.into_inner().password {
|
||||
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
|
||||
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
|
||||
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
|
||||
}
|
||||
}
|
||||
|
||||
// Files are incremented during the download
|
||||
if send.atype == SendType::Text as i32 {
|
||||
send.access_count += 1;
|
||||
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
process_access(send, conn, nt).await
|
||||
}
|
||||
|
||||
send.save(&conn).await?;
|
||||
|
||||
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
@@ -504,51 +391,38 @@ async fn post_access(
|
||||
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(
|
||||
send_id: SendId,
|
||||
file_id: SendFileId,
|
||||
data: Json<SendAccessData>,
|
||||
headers: SendHeaders,
|
||||
host: Host,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
||||
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
if send.access_count >= max_access_count {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(expiration) = send.expiration_date {
|
||||
if Utc::now().naive_utc() >= expiration {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
}
|
||||
|
||||
if Utc::now().naive_utc() >= send.deletion_date {
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if send.disabled {
|
||||
check_send_file_id(&send, &file_id)?;
|
||||
if !send.register_access(&conn).await? {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
process_access_file(send, file_id, host, conn, nt).await
|
||||
}
|
||||
|
||||
if send.password_hash.is_some() {
|
||||
match data.into_inner().password {
|
||||
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
|
||||
Some(_) => err!("Invalid password."),
|
||||
None => err_code!("Password not provided", 401),
|
||||
}
|
||||
fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
|
||||
if send.atype != SendType::File as i32 {
|
||||
err!("Send is not a file type send.");
|
||||
}
|
||||
match serde_json::from_str::<SendFileData>(&send.data) {
|
||||
Ok(data) if &data.id == file_id => Ok(()),
|
||||
_ => err_code!(SEND_INACCESSIBLE_MSG, 404),
|
||||
}
|
||||
}
|
||||
|
||||
send.access_count += 1;
|
||||
|
||||
send.save(&conn).await?;
|
||||
|
||||
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
@@ -561,18 +435,18 @@ async fn post_access_file(
|
||||
Ok(Json(json!({
|
||||
"object": "send-fileDownload",
|
||||
"id": file_id,
|
||||
"url": download_url(&host, &send_id, &file_id).await?,
|
||||
"url": download_url(&host, &send.uuid, &file_id).await?,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
||||
|
||||
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) {
|
||||
if crate::storage::is_fs_operator(&operator) {
|
||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||
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 {
|
||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
||||
}
|
||||
@@ -580,10 +454,10 @@ async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Re
|
||||
|
||||
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
||||
if claims.sub == format!("{send_id}/{file_id}") {
|
||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||
}
|
||||
if let Ok(claims) = crate::auth::decode_send(t)
|
||||
&& claims.sub == format!("{send_id}/{file_id}")
|
||||
{
|
||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -599,12 +473,16 @@ 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")
|
||||
};
|
||||
|
||||
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?;
|
||||
|
||||
Ok(Json(send.to_json()))
|
||||
}
|
||||
|
||||
pub async fn update_send_from_data(
|
||||
async fn update_send_from_data(
|
||||
send: &mut Send,
|
||||
data: SendData,
|
||||
headers: &Headers,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use data_encoding::BASE32;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
|
||||
auth::{ClientIp, Headers},
|
||||
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||
auth::{ClientIp, Headers, two_factor},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
util::NumberOrString,
|
||||
};
|
||||
@@ -21,27 +20,23 @@ pub fn routes() -> Vec<Route> {
|
||||
|
||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let type_ = TwoFactorType::Authenticator as i32;
|
||||
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
|
||||
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await;
|
||||
|
||||
let (enabled, key) = match twofactor {
|
||||
Some(tf) => (true, tf.data),
|
||||
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
|
||||
};
|
||||
|
||||
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
||||
// It should help prevent TOTP disclosure if someone keeps their vault unlocked.
|
||||
// Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response.
|
||||
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94
|
||||
Ok(Json(json!({
|
||||
"enabled": enabled,
|
||||
"key": key,
|
||||
"object": "twoFactorAuthenticator"
|
||||
"authenticator": json!({
|
||||
"enabled": enabled,
|
||||
"key": key,
|
||||
}),
|
||||
"userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -50,8 +45,7 @@ async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers,
|
||||
struct EnableAuthenticatorData {
|
||||
key: String,
|
||||
token: NumberOrString,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[post("/two-factor/authenticator", data = "<data>")]
|
||||
@@ -62,17 +56,13 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash,
|
||||
otp: data.otp,
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?;
|
||||
|
||||
// Validate key as base32 and 20 bytes length
|
||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
||||
Ok(decoded) => decoded,
|
||||
_ => err!("Invalid totp secret"),
|
||||
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||
decoded
|
||||
} else {
|
||||
err!("Invalid totp secret")
|
||||
};
|
||||
|
||||
if decoded_key.len() != 20 {
|
||||
@@ -82,14 +72,15 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
||||
// Validate the token provided with the key, and save new twofactor
|
||||
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": true,
|
||||
"key": key,
|
||||
"object": "twoFactorAuthenticator"
|
||||
"authenticator": json!({
|
||||
"enabled": true,
|
||||
"key": key,
|
||||
}),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -119,16 +110,15 @@ pub async fn validate_totp_code(
|
||||
ip: &ClientIp,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
use totp_lite::{totp_custom, Sha1};
|
||||
use totp_lite::{Sha1, totp_custom};
|
||||
|
||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||
err!("Invalid TOTP secret")
|
||||
};
|
||||
|
||||
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, conn).await {
|
||||
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
|
||||
@@ -145,7 +135,7 @@ pub async fn validate_totp_code(
|
||||
|
||||
// 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.
|
||||
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);
|
||||
|
||||
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
||||
@@ -184,36 +174,25 @@ pub async fn validate_totp_code(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DisableAuthenticatorData {
|
||||
key: String,
|
||||
master_password_hash: String,
|
||||
r#type: NumberOrString,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[delete("/two-factor/authenticator", data = "<data>")]
|
||||
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let user = headers.user;
|
||||
let type_ = data.r#type.into_i32()?;
|
||||
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?;
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await {
|
||||
if twofactor.data == data.key {
|
||||
twofactor.delete(&conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
} else {
|
||||
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
||||
}
|
||||
}
|
||||
|
||||
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
||||
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
}
|
||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"keys": type_,
|
||||
"object": "twoFactorProvider"
|
||||
})))
|
||||
Ok(())
|
||||
}
|
||||
+60
-203
@@ -1,74 +1,24 @@
|
||||
use chrono::Utc;
|
||||
use data_encoding::BASE64;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
|
||||
PasswordOrOtpData,
|
||||
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::log_user_event,
|
||||
core::two_factor::{VerificationTokenData, generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
auth::{Headers, two_factor, two_factor::DuoData},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
},
|
||||
error::MapResult,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_duo, activate_duo, activate_duo_put,]
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DuoData {
|
||||
host: String, // Duo API hostname
|
||||
ik: String, // client id
|
||||
sk: String, // client secret
|
||||
}
|
||||
|
||||
impl DuoData {
|
||||
fn global() -> Option<Self> {
|
||||
match (CONFIG._enable_duo(), CONFIG.duo_host()) {
|
||||
(true, Some(host)) => Some(Self {
|
||||
host,
|
||||
ik: CONFIG.duo_ikey().unwrap(),
|
||||
sk: CONFIG.duo_skey().unwrap(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn msg(s: &str) -> Self {
|
||||
Self {
|
||||
host: s.into(),
|
||||
ik: s.into(),
|
||||
sk: s.into(),
|
||||
}
|
||||
}
|
||||
fn secret() -> Self {
|
||||
Self::msg("<global_secret>")
|
||||
}
|
||||
fn obscure(self) -> Self {
|
||||
let mut host = self.host;
|
||||
let mut ik = self.ik;
|
||||
let mut sk = self.sk;
|
||||
|
||||
let digits = 4;
|
||||
let replaced = "************";
|
||||
|
||||
host.replace_range(digits.., replaced);
|
||||
ik.replace_range(digits.., replaced);
|
||||
sk.replace_range(digits.., replaced);
|
||||
|
||||
Self {
|
||||
host,
|
||||
ik,
|
||||
sk,
|
||||
}
|
||||
}
|
||||
routes![get_duo, activate_duo, activate_duo_put, disable_duo,]
|
||||
}
|
||||
|
||||
enum DuoStatus {
|
||||
@@ -82,8 +32,7 @@ enum DuoStatus {
|
||||
impl DuoStatus {
|
||||
fn data(self) -> Option<DuoData> {
|
||||
match self {
|
||||
DuoStatus::Global(data) => Some(data),
|
||||
DuoStatus::User(data) => Some(data),
|
||||
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
|
||||
DuoStatus::Disabled(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -98,22 +47,19 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
|
||||
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let data = get_user_duo_data(&user.uuid, &conn).await;
|
||||
|
||||
let (enabled, data) = match data {
|
||||
let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await {
|
||||
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
|
||||
DuoStatus::User(data) => (true, Some(data.obscure())),
|
||||
DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))),
|
||||
DuoStatus::Disabled(false) => (false, None),
|
||||
};
|
||||
|
||||
let json = if let Some(data) = data {
|
||||
let duo_json = if let Some(data) = duo.as_ref() {
|
||||
json!({
|
||||
"enabled": enabled,
|
||||
"host": data.host,
|
||||
"clientSecret": data.sk,
|
||||
"clientId": data.ik,
|
||||
"object": "twoFactorDuo"
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
@@ -121,11 +67,13 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn)
|
||||
"host": null,
|
||||
"clientSecret": null,
|
||||
"clientId": null,
|
||||
"object": "twoFactorDuo"
|
||||
})
|
||||
};
|
||||
|
||||
Ok(Json(json))
|
||||
Ok(Json(rocket::serde::json::json!({
|
||||
"duo": duo_json,
|
||||
"userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,8 +82,7 @@ struct EnableDuoData {
|
||||
host: String,
|
||||
client_secret: String,
|
||||
client_id: String,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
impl From<EnableDuoData> for DuoData {
|
||||
@@ -162,12 +109,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
||||
let data: EnableDuoData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash.clone(),
|
||||
otp: data.otp.clone(),
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?;
|
||||
|
||||
let (data, data_str) = if check_duo_fields_custom(&data) {
|
||||
let data_req: DuoData = data.into();
|
||||
@@ -182,16 +124,17 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": true,
|
||||
"host": data.host,
|
||||
"clientSecret": data.sk,
|
||||
"clientId": data.ik,
|
||||
"object": "twoFactorDuo"
|
||||
"duo": json!({
|
||||
"enabled": true,
|
||||
"host": data.host,
|
||||
"clientSecret": data.sk,
|
||||
"clientId": data.ik,
|
||||
}),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -200,15 +143,38 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
||||
activate_duo(data, headers, conn).await
|
||||
}
|
||||
|
||||
#[delete("/two-factor/duo", data = "<data>")]
|
||||
async fn disable_duo(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let user = headers.user;
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await {
|
||||
// Apply the same transformation than in `get_duo` to check we are disabling the correct one
|
||||
let duo = match to_user_duo_data(&twofactor) {
|
||||
DuoStatus::Global(_) => Some(DuoData::secret()),
|
||||
DuoStatus::User(data) => Some(data.obscure()),
|
||||
DuoStatus::Disabled(_) => None,
|
||||
};
|
||||
|
||||
two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?;
|
||||
|
||||
twofactor.delete(&conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
}
|
||||
|
||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||
use reqwest::{header, Method};
|
||||
use reqwest::{Method, header};
|
||||
use std::str::FromStr;
|
||||
|
||||
// https://duo.com/docs/authapi#api-details
|
||||
let url = format!("https://{}{path}", &data.host);
|
||||
let date = Utc::now().to_rfc2822();
|
||||
let url = format!("https://{}{path}", data.host);
|
||||
let dt = Utc::now().to_rfc2822();
|
||||
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 m = Method::from_str(method).unwrap_or_default();
|
||||
@@ -216,7 +182,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
||||
make_http_request(m, &url)?
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||
.header(header::DATE, date)
|
||||
.header(header::DATE, dt)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
@@ -224,21 +190,16 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DUO_EXPIRE: i64 = 300;
|
||||
const APP_EXPIRE: i64 = 3600;
|
||||
|
||||
const AUTH_PREFIX: &str = "AUTH";
|
||||
const DUO_PREFIX: &str = "TX";
|
||||
const APP_PREFIX: &str = "APP";
|
||||
|
||||
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
|
||||
let type_ = TwoFactorType::Duo as i32;
|
||||
|
||||
// If the user doesn't have an entry, disabled
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else {
|
||||
return DuoStatus::Disabled(DuoData::global().is_some());
|
||||
};
|
||||
|
||||
to_user_duo_data(&twofactor)
|
||||
}
|
||||
|
||||
fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus {
|
||||
// If the user has the required values, we use those
|
||||
if let Ok(data) = serde_json::from_str(&twofactor.data) {
|
||||
return DuoStatus::User(data);
|
||||
@@ -253,117 +214,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
|
||||
DuoStatus::Disabled(false)
|
||||
}
|
||||
|
||||
// let (ik, sk, ak, host) = get_duo_keys();
|
||||
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
|
||||
// let (ik, sk, host) = get_duo_keys_email();
|
||||
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> {
|
||||
let data = match User::find_by_mail(email, conn).await {
|
||||
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
|
||||
_ => DuoData::global(),
|
||||
}
|
||||
.map_res("Can't fetch Duo Keys")?;
|
||||
|
||||
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
|
||||
}
|
||||
|
||||
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE);
|
||||
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE);
|
||||
|
||||
Ok((format!("{duo_sign}:{app_sign}"), host))
|
||||
}
|
||||
|
||||
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String {
|
||||
let val = format!("{email}|{ikey}|{expire}");
|
||||
let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes()));
|
||||
|
||||
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
|
||||
}
|
||||
|
||||
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
|
||||
let split: Vec<&str> = response.split(':').collect();
|
||||
if split.len() != 2 {
|
||||
err!(
|
||||
"Invalid response length",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let auth_sig = split[0];
|
||||
let app_sig = split[1];
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?;
|
||||
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?;
|
||||
|
||||
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> {
|
||||
let split: Vec<&str> = val.split('|').collect();
|
||||
if split.len() != 3 {
|
||||
err!("Invalid value length")
|
||||
}
|
||||
|
||||
let u_prefix = split[0];
|
||||
let u_b64 = split[1];
|
||||
let u_sig = split[2];
|
||||
|
||||
let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}"));
|
||||
|
||||
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) {
|
||||
err!("Duo signatures don't match")
|
||||
}
|
||||
|
||||
if u_prefix != prefix {
|
||||
err!("Prefixes don't match")
|
||||
}
|
||||
|
||||
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
|
||||
err!("Invalid Duo cookie encoding")
|
||||
};
|
||||
|
||||
let Ok(cookie) = String::from_utf8(cookie_vec) else {
|
||||
err!("Invalid Duo cookie encoding")
|
||||
};
|
||||
|
||||
let cookie_split: Vec<&str> = cookie.split('|').collect();
|
||||
if cookie_split.len() != 3 {
|
||||
err!("Invalid cookie length")
|
||||
}
|
||||
|
||||
let username = cookie_split[0];
|
||||
let u_ikey = cookie_split[1];
|
||||
let expire = cookie_split[2];
|
||||
|
||||
if !crypto::ct_eq(ikey, u_ikey) {
|
||||
err!("Invalid ikey")
|
||||
}
|
||||
|
||||
let expire: i64 = match expire.parse() {
|
||||
Ok(e) => e,
|
||||
Err(_) => err!("Invalid expire time"),
|
||||
};
|
||||
|
||||
if time >= expire {
|
||||
err!("Expired authorization")
|
||||
}
|
||||
|
||||
Ok(username.into())
|
||||
Ok((data.ik, data.sk, data.host))
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use data_encoding::HEXLOWER;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use reqwest::{header, StatusCode};
|
||||
use ring::digest::{digest, Digest, SHA512_256};
|
||||
use reqwest::{StatusCode, header};
|
||||
use ring::digest::{Digest, SHA512_256, digest};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
||||
CONFIG,
|
||||
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
|
||||
crypto,
|
||||
db::{
|
||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||
DbConn, DbPool,
|
||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||
},
|
||||
error::Error,
|
||||
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
|
||||
// built in to the Bitwarden clients.
|
||||
@@ -124,7 +125,7 @@ impl DuoClient {
|
||||
ClientAssertion {
|
||||
iss: self.client_id.clone(),
|
||||
sub: self.client_id.clone(),
|
||||
aud: url.to_string(),
|
||||
aud: url.to_owned(),
|
||||
exp: now + JWT_VALIDITY_SECS,
|
||||
jti: jwt_id,
|
||||
iat: now,
|
||||
@@ -302,7 +303,7 @@ impl DuoClient {
|
||||
|
||||
if !(matching_nonces && matching_usernames) {
|
||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -347,7 +348,7 @@ pub async fn purge_duo_contexts(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
||||
error!("Failed to get DB connection while purging expired Duo authentications");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +383,7 @@ pub async fn get_duo_auth_url(
|
||||
device_identifier: &DeviceId,
|
||||
conn: &DbConn,
|
||||
) -> Result<String, Error> {
|
||||
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
||||
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
let callback_url = match make_callback_url(client_id) {
|
||||
Ok(url) => url,
|
||||
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate random OAuth2 state and OIDC Nonce
|
||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||
@@ -434,20 +435,17 @@ pub async fn validate_duo_login(
|
||||
let code = split[0];
|
||||
let state = split[1];
|
||||
|
||||
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
||||
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
// Get the context by the state reported by the client. If we don't have one,
|
||||
// it means the context is either missing or expired.
|
||||
let ctx = match extract_context(state, conn).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
let Some(ctx) = extract_context(state, conn).await else {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Context validation steps
|
||||
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||
|
||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{
|
||||
log_user_event,
|
||||
two_factor::{VerificationTokenData, generate_recover_code},
|
||||
},
|
||||
},
|
||||
auth::{ClientHeaders, Headers},
|
||||
auth::{ClientHeaders, Headers, two_factor},
|
||||
crypto,
|
||||
db::{
|
||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
|
||||
UserId,
|
||||
},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_email, send_email_login, send_email, email,]
|
||||
routes![get_email, send_email_login, send_email, email, disable_email]
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SendEmailLoginData {
|
||||
#[serde(alias = "DeviceIdentifier")]
|
||||
device_identifier: DeviceId,
|
||||
device_identifier: Option<DeviceId>,
|
||||
#[serde(alias = "Email")]
|
||||
email: Option<String>,
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
@@ -63,13 +69,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
||||
|
||||
let user = if let Some(email) = email {
|
||||
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
err!(
|
||||
"Username or password is incorrect. Try again",
|
||||
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(master_password_hash) = master_password_hash {
|
||||
// Check password
|
||||
if !user.check_valid_password(master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
err!(
|
||||
"Username or password is incorrect. Try again",
|
||||
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
|
||||
)
|
||||
}
|
||||
} else if let Some(auth_request_id) = auth_request_id {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||
@@ -85,15 +97,35 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
||||
{
|
||||
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||
}
|
||||
} else if let Some(device_identifier) = &data.device_identifier {
|
||||
// iOS/Android SSO logins send the email and device id but no password hash,
|
||||
// so accept a device that has a pending 2FA login for this user
|
||||
if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() {
|
||||
err!(
|
||||
"Username or password is incorrect. Try again",
|
||||
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
|
||||
)
|
||||
}
|
||||
debug!(
|
||||
"Email 2FA fallback: pending login. Username: {}. Device: {}.",
|
||||
user.email,
|
||||
device_identifier.to_string().escape_debug()
|
||||
);
|
||||
} else {
|
||||
err!("No password hash has been submitted.")
|
||||
}
|
||||
|
||||
user
|
||||
} else {
|
||||
let Some(device_identifier) = &data.device_identifier else {
|
||||
err!("No device identifier has been submitted.")
|
||||
};
|
||||
// SSO login only sends device id, so we get the user by the most recently used device
|
||||
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
|
||||
err!(
|
||||
"Username or password is incorrect. Try again",
|
||||
format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug())
|
||||
)
|
||||
};
|
||||
|
||||
user
|
||||
@@ -104,8 +136,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
||||
|
||||
/// Generate the token, save the data for later verification and send email to user
|
||||
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::Email as i32;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
|
||||
let mut twofactor =
|
||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
||||
|
||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||
|
||||
@@ -128,18 +160,19 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let (enabled, mfa_email) =
|
||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await {
|
||||
Some(x) => {
|
||||
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
||||
(true, json!(twofactor_data.email))
|
||||
}
|
||||
_ => (false, serde_json::value::Value::Null),
|
||||
if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
||||
(true, Some(twofactor_data.email))
|
||||
} else {
|
||||
(false, None)
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"email": mfa_email,
|
||||
"enabled": enabled,
|
||||
"object": "twoFactorEmail"
|
||||
Ok(Json(rocket::serde::json::json!({
|
||||
"email": rocket::serde::json::json!({
|
||||
"enabled": enabled,
|
||||
"email": mfa_email,
|
||||
}),
|
||||
"userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -148,43 +181,36 @@ async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn
|
||||
struct SendEmailData {
|
||||
/// Email where 2FA codes will be sent to, can be different than user email account.
|
||||
email: String,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
||||
#[post("/two-factor/send-email", data = "<data>")]
|
||||
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: SendEmailData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash,
|
||||
otp: data.otp,
|
||||
}
|
||||
.validate(&user, false, &conn)
|
||||
.await?;
|
||||
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?;
|
||||
|
||||
if !CONFIG._enable_email_2fa() {
|
||||
err!("Email 2FA is disabled")
|
||||
}
|
||||
|
||||
let type_ = TwoFactorType::Email as i32;
|
||||
|
||||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||
tf.delete(&conn).await?;
|
||||
}
|
||||
|
||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||
let twofactor_data = EmailTokenData::new(data.email, generated_token);
|
||||
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
|
||||
|
||||
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
||||
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
||||
let twofactor =
|
||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
||||
twofactor.save(&conn).await?;
|
||||
|
||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
|
||||
|
||||
Ok(())
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
@@ -192,8 +218,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
|
||||
struct EmailData {
|
||||
email: String,
|
||||
token: String,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
/// Verify email belongs to user and can be used for 2FA email codes.
|
||||
@@ -202,17 +227,11 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
||||
let data: EmailData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
// This is the last step in the verification process, delete the otp directly afterwards
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash,
|
||||
otp: data.otp,
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?;
|
||||
|
||||
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
||||
let mut twofactor =
|
||||
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn)
|
||||
.await
|
||||
.map_res("Two factor not found")?;
|
||||
|
||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||
|
||||
@@ -229,15 +248,28 @@ async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
|
||||
twofactor.data = email_data.to_json();
|
||||
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"email": email_data.email,
|
||||
"enabled": "true",
|
||||
"object": "twoFactorEmail"
|
||||
})))
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[delete("/two-factor/email", data = "<data>")]
|
||||
async fn disable_email(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let user = headers.user;
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await {
|
||||
let twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||
two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?;
|
||||
|
||||
twofactor.delete(&conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
}
|
||||
|
||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the email code when used as TwoFactor token mechanism
|
||||
@@ -249,9 +281,8 @@ pub async fn validate_email_code_str(
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let mut email_data = EmailTokenData::from_json(data)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
|
||||
.await
|
||||
.map_res("Two factor not found")?;
|
||||
let mut twofactor =
|
||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?;
|
||||
let Some(issued_token) = &email_data.last_token else {
|
||||
err!(
|
||||
format!("No token available! IP: {ip}"),
|
||||
@@ -281,9 +312,9 @@ pub async fn validate_email_code_str(
|
||||
twofactor.data = email_data.to_json();
|
||||
twofactor.save(conn).await?;
|
||||
|
||||
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||
let max_time = CONFIG.email_expiration_time() as i64;
|
||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().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().cast_signed();
|
||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||
err!(
|
||||
"Token has expired",
|
||||
ErrorEvent {
|
||||
@@ -310,10 +341,10 @@ pub struct EmailTokenData {
|
||||
}
|
||||
|
||||
impl EmailTokenData {
|
||||
pub fn new(email: String, token: String) -> EmailTokenData {
|
||||
pub fn new(email: String, token: Option<String>) -> EmailTokenData {
|
||||
EmailTokenData {
|
||||
email,
|
||||
last_token: Some(token),
|
||||
last_token: token,
|
||||
token_sent: Utc::now().timestamp(),
|
||||
attempts: 0,
|
||||
}
|
||||
@@ -339,9 +370,10 @@ impl EmailTokenData {
|
||||
|
||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
||||
if let Ok(x) = res {
|
||||
Ok(x)
|
||||
} else {
|
||||
err!("Could not decode EmailTokenData from string")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +382,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
if user.verified_at.is_none() {
|
||||
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
|
||||
}
|
||||
let twofactor_data = EmailTokenData::new(user.email.clone(), String::new());
|
||||
// The token is set when the first code is sent
|
||||
let twofactor_data = EmailTokenData::new(user.email.clone(), None);
|
||||
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
|
||||
twofactor.save(conn).await
|
||||
}
|
||||
@@ -359,18 +392,17 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
||||
pub fn obscure_email(email: &str) -> String {
|
||||
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 name_size = name.chars().count();
|
||||
|
||||
let new_name = match name_size {
|
||||
1..=3 => "*".repeat(name_size),
|
||||
_ => {
|
||||
let stars = "*".repeat(name_size - 2);
|
||||
name.truncate(2);
|
||||
format!("{name}{stars}")
|
||||
}
|
||||
let new_name = if let 1..=3 = name_size {
|
||||
"*".repeat(name_size)
|
||||
} else {
|
||||
let stars = "*".repeat(name_size - 2);
|
||||
name.truncate(2);
|
||||
format!("{name}{stars}")
|
||||
};
|
||||
|
||||
format!("{new_name}@{domain}")
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use data_encoding::BASE32;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::{log_event, log_user_event},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
},
|
||||
CONFIG,
|
||||
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{
|
||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
|
||||
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub mod authenticator;
|
||||
@@ -37,7 +32,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()
|
||||
}
|
||||
|
||||
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)]
|
||||
struct DuoProviderData {
|
||||
host: String,
|
||||
@@ -46,7 +41,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
||||
}
|
||||
|
||||
match provider_type {
|
||||
TwoFactorType::Authenticator => true,
|
||||
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
|
||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||
provider_data
|
||||
@@ -59,7 +54,6 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
||||
}
|
||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||
TwoFactorType::RecoveryCode => true,
|
||||
TwoFactorType::U2f
|
||||
| TwoFactorType::U2fRegisterChallenge
|
||||
| TwoFactorType::U2fLoginChallenge
|
||||
@@ -71,13 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data:
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut routes = routes![
|
||||
get_twofactor,
|
||||
get_recover,
|
||||
disable_twofactor,
|
||||
disable_twofactor_put,
|
||||
get_device_verification_settings,
|
||||
];
|
||||
let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings];
|
||||
|
||||
routes.append(&mut authenticator::routes());
|
||||
routes.append(&mut duo::routes());
|
||||
@@ -89,6 +77,12 @@ pub fn routes() -> Vec<Route> {
|
||||
routes
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerificationTokenData {
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[get("/two-factor")]
|
||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||
@@ -96,7 +90,7 @@ async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -120,7 +114,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() {
|
||||
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||
user.totp_recover = Some(totp_recover);
|
||||
@@ -128,49 +122,14 @@ async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DisableTwoFactorData {
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
r#type: NumberOrString,
|
||||
}
|
||||
|
||||
#[post("/two-factor/disable", data = "<data>")]
|
||||
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: DisableTwoFactorData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
// Delete directly after a valid token has been provided
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash,
|
||||
otp: data.otp,
|
||||
/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping
|
||||
/// the providers that are left, and once every provider is gone the 2FA policy applies.
|
||||
pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult {
|
||||
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
||||
if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() {
|
||||
enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?;
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
|
||||
let type_ = data.r#type.into_i32()?;
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
twofactor.delete(&conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
}
|
||||
|
||||
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
||||
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"type": type_,
|
||||
"object": "twoFactorProvider"
|
||||
})))
|
||||
}
|
||||
|
||||
#[put("/two-factor/disable", data = "<data>")]
|
||||
async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
disable_twofactor(data, headers, conn).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn enforce_2fa_policy(
|
||||
@@ -180,9 +139,7 @@ pub async fn enforce_2fa_policy(
|
||||
ip: &std::net::IpAddr,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
for member in
|
||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
||||
{
|
||||
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||
if member.atype < MembershipType::Admin {
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -194,7 +151,7 @@ pub async fn enforce_2fa_policy(
|
||||
member.save(conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserRevoked as i32,
|
||||
EventType::OrganizationUserRevoked,
|
||||
&member.uuid,
|
||||
&member.org_uuid,
|
||||
act_user_id,
|
||||
@@ -217,7 +174,7 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
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.
|
||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -228,16 +185,8 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
member.revoke();
|
||||
member.save(conn).await?;
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserRevoked as i32,
|
||||
&member.uuid,
|
||||
org_id,
|
||||
act_user_id,
|
||||
device_type,
|
||||
ip,
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,12 +200,9 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
return;
|
||||
}
|
||||
|
||||
let conn = match pool.get().await {
|
||||
Ok(conn) => conn,
|
||||
_ => {
|
||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||
return;
|
||||
}
|
||||
let Ok(conn) = pool.get().await else {
|
||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||
return;
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -278,7 +224,7 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
if let Err(e) = login.delete(&conn).await {
|
||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc};
|
||||
use rocket::{serde::json::Json, Route};
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::EmptyResult,
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
models::{TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -44,9 +45,10 @@ impl ProtectedActionData {
|
||||
|
||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
||||
if let Ok(x) = res {
|
||||
Ok(x)
|
||||
} else {
|
||||
err!("Could not decode ProtectedActionData from string")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,14 +64,15 @@ impl ProtectedActionData {
|
||||
#[post("/accounts/request-otp")]
|
||||
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
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;
|
||||
|
||||
// Only one Protected Action per user is allowed to take place, delete the previous one
|
||||
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await
|
||||
{
|
||||
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await {
|
||||
let pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||
let elapsed = pa_data.time_since_sent().num_seconds();
|
||||
let delay = 30;
|
||||
@@ -102,7 +105,9 @@ struct ProtectedActionVerify {
|
||||
#[post("/accounts/verify-otp", data = "<data>")]
|
||||
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
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;
|
||||
@@ -119,7 +124,7 @@ pub async fn validate_protected_action_otp(
|
||||
delete_if_valid: bool,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
|
||||
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn)
|
||||
.await
|
||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||
@@ -133,7 +138,7 @@ pub async fn validate_protected_action_otp(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
pa.delete(conn).await?;
|
||||
err!("Token has expired")
|
||||
|
||||
+151
-111
@@ -1,34 +1,37 @@
|
||||
use crate::{
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
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 std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration};
|
||||
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
|
||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
||||
use webauthn_rs::{
|
||||
Webauthn, WebauthnBuilder,
|
||||
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
|
||||
};
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
||||
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{
|
||||
log_user_event,
|
||||
two_factor::{VerificationTokenData, generate_recover_code},
|
||||
},
|
||||
},
|
||||
auth::{Headers, two_factor},
|
||||
crypto::ct_eq,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
error::Error,
|
||||
};
|
||||
|
||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
let domain = CONFIG.domain();
|
||||
let domain_origin = CONFIG.domain_origin();
|
||||
@@ -44,7 +47,14 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
|
||||
routes![
|
||||
get_webauthn,
|
||||
generate_webauthn_challenge,
|
||||
activate_webauthn,
|
||||
activate_webauthn_put,
|
||||
delete_webauthn,
|
||||
delete_webauthns
|
||||
]
|
||||
}
|
||||
|
||||
// Some old u2f structs still needed for migrating from u2f to WebAuthn
|
||||
@@ -118,38 +128,40 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbC
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
||||
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": enabled,
|
||||
"keys": registrations_json,
|
||||
"object": "twoFactorWebAuthn"
|
||||
"webAuthn": json!({
|
||||
"enabled": enabled,
|
||||
"keys": registrations_json,
|
||||
}),
|
||||
"userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled),
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
|
||||
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
async fn generate_webauthn_challenge(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let registrations = get_webauthn_registrations(&user.uuid, &conn)
|
||||
.await?
|
||||
.1
|
||||
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
||||
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||
let creds = registrations
|
||||
.into_iter()
|
||||
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
|
||||
.collect();
|
||||
|
||||
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?;
|
||||
|
||||
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||
&user.email,
|
||||
user.display_name(),
|
||||
Some(registrations),
|
||||
Some(creds),
|
||||
)?;
|
||||
|
||||
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();
|
||||
|
||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||
@@ -165,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
||||
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
||||
challenge_value["status"] = "ok".into();
|
||||
challenge_value["errorMessage"] = "".into();
|
||||
Ok(Json(challenge_value))
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": challenge_value
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnableWebauthnData {
|
||||
id: NumberOrString, // 1..5
|
||||
id: i32,
|
||||
name: String,
|
||||
device_response: RegisterPublicKeyCredentialCopy,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -256,31 +270,27 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
let data: EnableWebauthnData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash,
|
||||
otp: data.otp,
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
||||
let keys: Vec<i32> = registrations.iter().map(|r| r.id).collect();
|
||||
two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?;
|
||||
|
||||
// Retrieve and delete the saved challenge state
|
||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
Some(tf) => {
|
||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(&conn).await?;
|
||||
state
|
||||
}
|
||||
None => err!("Can't recover challenge"),
|
||||
let state = if let Some(tf) =
|
||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await
|
||||
{
|
||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(&conn).await?;
|
||||
state
|
||||
} else {
|
||||
err!("Can't recover challenge")
|
||||
};
|
||||
|
||||
// Verify the credentials with the saved state
|
||||
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
|
||||
|
||||
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
||||
// TODO: Check for repeated ID's
|
||||
registrations.push(WebauthnRegistration {
|
||||
id: data.id.into_i32()?,
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
migrated: false,
|
||||
|
||||
@@ -291,15 +301,15 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||
Ok(Json(json!({
|
||||
"enabled": true,
|
||||
"keys": keys_json,
|
||||
"object": "twoFactorU2f"
|
||||
"webAuthn": json!({
|
||||
"enabled": true,
|
||||
"keys": registrations.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
||||
}),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -310,56 +320,87 @@ async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers,
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteU2FData {
|
||||
id: NumberOrString,
|
||||
master_password_hash: String,
|
||||
struct DeleteWebauthnData {
|
||||
id: i32,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[delete("/two-factor/webauthn", data = "<data>")]
|
||||
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let id = data.id.into_i32()?;
|
||||
if !headers.user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
async fn delete_webauthn(data: Json<DeleteWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await
|
||||
}
|
||||
|
||||
let Some(mut tf) =
|
||||
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
|
||||
else {
|
||||
#[delete("/two-factor/webauthn/all", data = "<data>")]
|
||||
async fn delete_webauthns(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await
|
||||
}
|
||||
|
||||
async fn inner_delete_webauthns(
|
||||
token: &str,
|
||||
retain: impl Fn(&WebauthnRegistration) -> bool,
|
||||
headers: Headers,
|
||||
conn: &DbConn,
|
||||
) -> JsonResult {
|
||||
let user = headers.user;
|
||||
|
||||
let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else {
|
||||
err!("Webauthn data not found!")
|
||||
};
|
||||
|
||||
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||
let mut keys: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||
let keys_id: Vec<i32> = keys.iter().map(|r| r.id).collect();
|
||||
|
||||
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
|
||||
two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?;
|
||||
|
||||
let mut removed: HashSet<Vec<u8>> = HashSet::new();
|
||||
let mut migrated = false;
|
||||
|
||||
keys.retain(|key| {
|
||||
let retained = retain(key);
|
||||
if !retained {
|
||||
removed.insert(key.credential.cred_id().to_vec());
|
||||
migrated = migrated || key.migrated;
|
||||
}
|
||||
retained
|
||||
});
|
||||
|
||||
if removed.is_empty() {
|
||||
err!("Webauthn entry not found")
|
||||
};
|
||||
|
||||
let removed_item = data.remove(item_pos);
|
||||
tf.data = serde_json::to_string(&data)?;
|
||||
tf.save(&conn).await?;
|
||||
drop(tf);
|
||||
|
||||
// If entry is migrated from u2f, delete the u2f entry as well
|
||||
if 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) {
|
||||
Ok(d) => d,
|
||||
Err(_) => err!("Error parsing U2F data"),
|
||||
};
|
||||
|
||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||
let new_data_str = serde_json::to_string(&data)?;
|
||||
|
||||
u2f.data = new_data_str;
|
||||
u2f.save(&conn).await?;
|
||||
}
|
||||
|
||||
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
|
||||
if keys.is_empty() {
|
||||
tf.delete(conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await;
|
||||
} else {
|
||||
tf.data = serde_json::to_string(&keys)?;
|
||||
tf.save(conn).await?;
|
||||
drop(tf);
|
||||
}
|
||||
|
||||
// If entry is migrated from u2f, delete the u2f entry as well
|
||||
if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await {
|
||||
let Ok(mut data) = serde_json::from_str::<Vec<U2FRegistration>>(&u2f.data) else {
|
||||
err!("Error parsing U2F data")
|
||||
};
|
||||
|
||||
data.retain(|old| !removed.contains(&old.reg.key_handle));
|
||||
|
||||
if data.is_empty() {
|
||||
u2f.delete(conn).await?;
|
||||
} else {
|
||||
let new_data_str = serde_json::to_string(&data)?;
|
||||
u2f.data = new_data_str;
|
||||
u2f.save(conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": true,
|
||||
"keys": keys_json,
|
||||
"object": "twoFactorU2f"
|
||||
"webAuthn": json!({
|
||||
"enabled": !keys.is_empty(),
|
||||
"keys": keys.iter().map(WebauthnRegistration::to_json).collect::<Vec<Value>>(),
|
||||
}),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -367,8 +408,7 @@ pub async fn get_webauthn_registrations(
|
||||
user_id: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
||||
let type_ = TwoFactorType::Webauthn as i32;
|
||||
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await {
|
||||
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
|
||||
None => Ok((false, Vec::new())), // If no data, return empty list
|
||||
}
|
||||
@@ -388,10 +428,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
|
||||
// Modify to discourage user verification
|
||||
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
|
||||
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());
|
||||
|
||||
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||
@@ -415,19 +455,19 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
}
|
||||
|
||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||
let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
Some(tf) => {
|
||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(conn).await?;
|
||||
state
|
||||
}
|
||||
None => err!(
|
||||
let mut state = if let Some(tf) =
|
||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await
|
||||
{
|
||||
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(conn).await?;
|
||||
state
|
||||
} else {
|
||||
err!(
|
||||
"Can't recover login challenge",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde_json::Value;
|
||||
use yubico::{config::Config, verify_async};
|
||||
use yubico_ng::{
|
||||
Verifier, YubicoError,
|
||||
config::Config,
|
||||
transport::{AsyncTransport, Response},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_user_event, two_factor::_generate_recover_code},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{
|
||||
log_user_event,
|
||||
two_factor::{VerificationTokenData, generate_recover_code},
|
||||
},
|
||||
},
|
||||
auth::Headers,
|
||||
auth::{Headers, two_factor},
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
CONFIG,
|
||||
http_client,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
|
||||
routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,]
|
||||
}
|
||||
|
||||
struct HttpClientTransport {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HttpClientTransport {
|
||||
fn new() -> Result<Self, reqwest::Error> {
|
||||
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|
||||
|client| Self {
|
||||
client,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncTransport for HttpClientTransport {
|
||||
type Error = YubicoError;
|
||||
|
||||
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
|
||||
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
|
||||
Ok(Response {
|
||||
status: response.status().as_u16(),
|
||||
body: response.text().await.map_err(YubicoError::transport)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -30,8 +63,7 @@ struct EnableYubikeyData {
|
||||
key4: Option<String>,
|
||||
key5: Option<String>,
|
||||
nfc: bool,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
user_verification_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
@@ -45,8 +77,7 @@ pub struct YubikeyMetadata {
|
||||
|
||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||
|
||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
||||
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||
@@ -64,22 +95,25 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
||||
err!("Yubico support is disabled");
|
||||
}
|
||||
|
||||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||
(Some(id), Some(secret)) => Ok((id, secret)),
|
||||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
|
||||
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||
Ok((id, secret))
|
||||
} 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 {
|
||||
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
|
||||
|
||||
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
|
||||
|
||||
match CONFIG.yubico_server() {
|
||||
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
|
||||
None => verify_async(otp, config).await,
|
||||
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
|
||||
if let Some(yubico_server) = CONFIG.yubico_server() {
|
||||
config = config.set_api_host(yubico_server);
|
||||
}
|
||||
.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>")]
|
||||
@@ -93,56 +127,47 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn:
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let user_id = &user.uuid;
|
||||
let yubikey_type = TwoFactorType::YubiKey as i32;
|
||||
|
||||
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
|
||||
|
||||
if let Some(r) = r {
|
||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||
|
||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
||||
|
||||
result["enabled"] = Value::Bool(true);
|
||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||
result["object"] = Value::String("twoFactorU2f".to_owned());
|
||||
|
||||
Ok(Json(result))
|
||||
} else {
|
||||
Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"object": "twoFactorU2f",
|
||||
})))
|
||||
}
|
||||
let (enabled, keys, yubikey_json) =
|
||||
if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await {
|
||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||
let enabled = !yubikey_metadata.keys.is_empty();
|
||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone());
|
||||
result["enabled"] = Value::Bool(enabled);
|
||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||
(enabled, yubikey_metadata.keys, result)
|
||||
} else {
|
||||
(false, Vec::new(), json!({"enabled": false}))
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"yubiKey": yubikey_json,
|
||||
"userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled),
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/yubikey", data = "<data>")]
|
||||
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: EnableYubikeyData = data.into_inner();
|
||||
let yubikeys = parse_yubikeys(&data);
|
||||
let mut user = headers.user;
|
||||
|
||||
PasswordOrOtpData {
|
||||
master_password_hash: data.master_password_hash.clone(),
|
||||
otp: data.otp.clone(),
|
||||
if yubikeys.is_empty() {
|
||||
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
|
||||
// To remove all keys users should click the `Deactivate all keys` button
|
||||
err!("A key is required.");
|
||||
}
|
||||
.validate(&user, true, &conn)
|
||||
.await?;
|
||||
|
||||
// Check if we already have some data
|
||||
let mut yubikey_data =
|
||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
|
||||
Some(data) => data,
|
||||
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
|
||||
if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
|
||||
let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?;
|
||||
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?;
|
||||
yd
|
||||
} else {
|
||||
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?;
|
||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new())
|
||||
};
|
||||
|
||||
let yubikeys = parse_yubikeys(&data);
|
||||
|
||||
if yubikeys.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"object": "twoFactorU2f",
|
||||
})));
|
||||
}
|
||||
|
||||
// Ensure they are valid OTPs
|
||||
for yubikey in &yubikeys {
|
||||
if yubikey.is_empty() || yubikey.len() == 12 {
|
||||
@@ -162,17 +187,14 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||
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, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
|
||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
||||
|
||||
result["enabled"] = Value::Bool(true);
|
||||
result["nfc"] = Value::Bool(yubikey_metadata.nfc);
|
||||
result["object"] = Value::String("twoFactorU2f".to_owned());
|
||||
|
||||
Ok(Json(result))
|
||||
Ok(Json(json!({"yubiKey": result})))
|
||||
}
|
||||
|
||||
#[put("/two-factor/yubikey", data = "<data>")]
|
||||
@@ -180,13 +202,32 @@ async fn activate_yubikey_put(data: Json<EnableYubikeyData>, headers: Headers, c
|
||||
activate_yubikey(data, headers, conn).await
|
||||
}
|
||||
|
||||
#[delete("/two-factor/yubikey", data = "<data>")]
|
||||
async fn delete_yubikeys(data: Json<VerificationTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let user = headers.user;
|
||||
|
||||
if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await {
|
||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||
two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?;
|
||||
|
||||
r.delete(&conn).await?;
|
||||
log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||
}
|
||||
|
||||
super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
|
||||
if response.len() != 44 {
|
||||
err!("Invalid Yubikey OTP length");
|
||||
}
|
||||
|
||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
|
||||
let response_id = &response[..12];
|
||||
let Some(response_id) = response.get(..12) else {
|
||||
err!("Invalid Yubikey OTP");
|
||||
};
|
||||
|
||||
if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
|
||||
err!("Given Yubikey is not registered");
|
||||
|
||||
+56
-41
@@ -6,28 +6,29 @@ use std::{
|
||||
};
|
||||
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::{stream::StreamExt, TryFutureExt};
|
||||
use futures::{TryFutureExt, stream::StreamExt};
|
||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||
use regex::Regex;
|
||||
use reqwest::{
|
||||
header::{self, HeaderMap, HeaderValue},
|
||||
Client, Response,
|
||||
header::{self, HeaderMap, HeaderValue},
|
||||
};
|
||||
use rocket::{http::ContentType, response::Redirect, Route};
|
||||
use svg_hush::{data_url_filter, Filter};
|
||||
use rocket::{Route, http::ContentType, response::Redirect};
|
||||
use svg_hush::{Filter, data_url_filter};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
config::PathType,
|
||||
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,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
match CONFIG.icon_service().as_str() {
|
||||
"internal" => routes![icon_internal],
|
||||
_ => routes![icon_external],
|
||||
if CONFIG.icon_service().as_str() == "internal" {
|
||||
routes![icon_internal]
|
||||
} 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 pool_idle_timeout = Duration::from_secs(10);
|
||||
// Reuse the client between requests
|
||||
get_reqwest_client_builder()
|
||||
get_reqwest_client_builder(true)
|
||||
.cookie_provider(Arc::clone(&cookie_store))
|
||||
.timeout(icon_download_timeout)
|
||||
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
||||
@@ -84,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
|
||||
#[get("/<host>/icon.png")]
|
||||
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
warn!("Invalid host: {}", host.escape_debug());
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
};
|
||||
|
||||
@@ -112,7 +113,7 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
|
||||
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
warn!("Invalid host: {}", host.escape_debug());
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
@@ -147,7 +148,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
|
||||
if let Some(icon) = get_cached_icon(&path).await {
|
||||
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() {
|
||||
@@ -158,7 +159,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
match download_icon(domain).await {
|
||||
Ok((icon, icon_type)) => {
|
||||
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) => {
|
||||
// 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
|
||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||
if let Ok(buf) = operator.read(path).await {
|
||||
return Some(buf.to_vec());
|
||||
}
|
||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)
|
||||
&& let Ok(buf) = operator.read(path).await
|
||||
{
|
||||
return Some(buf.to_vec());
|
||||
}
|
||||
|
||||
None
|
||||
@@ -280,17 +281,17 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
||||
}
|
||||
|
||||
for icon_tag in icon_tags {
|
||||
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 sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||
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()));
|
||||
}
|
||||
};
|
||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF)
|
||||
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default())
|
||||
{
|
||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
// 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);
|
||||
if !referer.is_empty() {
|
||||
client = client.header("Referer", referer)
|
||||
client = client.header("Referer", referer);
|
||||
}
|
||||
|
||||
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 icon_type: Option<&str> = None;
|
||||
|
||||
use data_url::DataUrl;
|
||||
|
||||
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
||||
while let Some(icon) = icons.next() {
|
||||
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;
|
||||
};
|
||||
// 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"),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
debug!("Trying {}", icon.href);
|
||||
// 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 check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
||||
// Look for SVG tag within the first 1KB
|
||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) {
|
||||
if content.contains("<svg") || content.contains("<SVG") {
|
||||
return Some("svg+xml");
|
||||
}
|
||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)])
|
||||
&& (content.contains("<svg") || content.contains("<SVG"))
|
||||
{
|
||||
return Some("svg+xml");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -733,7 +748,7 @@ impl FaviconEmitter {
|
||||
let rel_value =
|
||||
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
||||
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]) {
|
||||
if let Some(attr) = &mut self.current_attribute {
|
||||
attr.0.extend(s)
|
||||
attr.0.extend(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_attribute_value(&mut self, s: &[u8]) {
|
||||
if let Some(attr) = &mut self.current_attribute {
|
||||
attr.1.extend(s)
|
||||
attr.1.extend(s);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+271
-251
@@ -1,50 +1,50 @@
|
||||
use chrono::Utc;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{
|
||||
Route,
|
||||
form::{Form, FromForm},
|
||||
http::{Cookie, CookieJar, SameSite},
|
||||
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
|
||||
response::Redirect,
|
||||
serde::json::Json,
|
||||
Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
core::{
|
||||
accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
|
||||
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
|
||||
log_user_event,
|
||||
two_factor::{
|
||||
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
|
||||
yubikey,
|
||||
authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
|
||||
},
|
||||
},
|
||||
master_password_policy,
|
||||
identity_master_password_policy,
|
||||
push::register_push_device,
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
},
|
||||
auth,
|
||||
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure},
|
||||
auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims},
|
||||
crypto,
|
||||
db::{
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
|
||||
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
DbConn,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
||||
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||
TwoFactorType, User, UserId,
|
||||
},
|
||||
},
|
||||
error::MapResult,
|
||||
mail, sso,
|
||||
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
|
||||
util, CONFIG,
|
||||
util,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
login,
|
||||
prelogin,
|
||||
post_prelogin,
|
||||
prelogin_password,
|
||||
identity_register,
|
||||
register_verification_email,
|
||||
register_finish,
|
||||
prevalidate,
|
||||
@@ -55,57 +55,62 @@ pub fn routes() -> Vec<Route> {
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<data>")]
|
||||
async fn login(
|
||||
data: Form<ConnectData>,
|
||||
client_header: ClientHeaders,
|
||||
client_version: Option<ClientVersion>,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbConn) -> JsonResult {
|
||||
let data: ConnectData = data.into_inner();
|
||||
|
||||
let mut user_id: Option<UserId> = None;
|
||||
|
||||
let login_result = match data.grant_type.as_ref() {
|
||||
"refresh_token" => {
|
||||
_check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
||||
_refresh_login(data, &conn, &client_header.ip).await
|
||||
check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
|
||||
refresh_login(data, &conn, &client_header.ip).await
|
||||
}
|
||||
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||
"password" => {
|
||||
_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.scope.as_ref(), "scope cannot be blank")?;
|
||||
_check_is_some(data.username.as_ref(), "username 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.scope.as_ref(), "scope 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_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_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
_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).await
|
||||
}
|
||||
"client_credentials" => {
|
||||
_check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
_check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||
_check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||
|
||||
_check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||
_check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
_check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
_api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
"authorization_code" if CONFIG.sso_enabled() => {
|
||||
_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_verifier.as_ref(), "code verifier 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_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_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_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
_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).await
|
||||
}
|
||||
"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),
|
||||
};
|
||||
|
||||
@@ -113,7 +118,7 @@ async fn login(
|
||||
match &login_result {
|
||||
Ok(_) => {
|
||||
log_user_event(
|
||||
EventType::UserLoggedIn as i32,
|
||||
EventType::UserLoggedIn,
|
||||
&user_id,
|
||||
client_header.device_type,
|
||||
&client_header.ip.ip,
|
||||
@@ -123,8 +128,7 @@ async fn login(
|
||||
}
|
||||
Err(e) => {
|
||||
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)
|
||||
.await
|
||||
log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +137,7 @@ async fn login(
|
||||
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)
|
||||
// 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
|
||||
@@ -174,19 +178,13 @@ 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
|
||||
async fn _sso_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
ip: &ClientIp,
|
||||
client_version: Option<&ClientVersion>,
|
||||
) -> JsonResult {
|
||||
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
|
||||
let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
|
||||
(None, _) => err!(
|
||||
"Got no code in OIDC data",
|
||||
ErrorEvent {
|
||||
@@ -202,7 +200,7 @@ async fn _sso_login(
|
||||
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
|
||||
};
|
||||
|
||||
let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?;
|
||||
let (sso_auth, user_infos) = sso::exchange_code(code, code_verifier, conn).await?;
|
||||
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
||||
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
||||
None => None,
|
||||
@@ -218,6 +216,24 @@ async fn _sso_login(
|
||||
}
|
||||
)
|
||||
}
|
||||
Some((user, None))
|
||||
if user.private_key.is_none()
|
||||
&& !CONFIG.sso_signups_allowed()
|
||||
&& !CONFIG.is_email_domain_allowed(&user.email)
|
||||
&& !CONFIG.mail_enabled()
|
||||
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
|
||||
{
|
||||
error!(
|
||||
"Login failure ({}), no invitation with email ({}) was found",
|
||||
user_infos.identifier, user.email
|
||||
);
|
||||
err_silent!(
|
||||
"Missing invitation",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
||||
error!(
|
||||
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
||||
@@ -265,7 +281,15 @@ async fn _sso_login(
|
||||
// Will trigger 2FA flow if needed
|
||||
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
||||
None => {
|
||||
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
|
||||
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
|
||||
if CONFIG.signups_domains_whitelist().is_empty() {
|
||||
err!(
|
||||
"Signups are disabled. You will need an invitation",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
);
|
||||
}
|
||||
err!(
|
||||
"Email domain not allowed",
|
||||
ErrorEvent {
|
||||
@@ -302,7 +326,7 @@ async fn _sso_login(
|
||||
Some((user, _)) if !user.enabled => {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
@@ -311,7 +335,7 @@ async fn _sso_login(
|
||||
Some((mut user, sso_user)) => {
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||
|
||||
if user.private_key.is_none() {
|
||||
// User was invited a stub was created
|
||||
@@ -343,13 +367,7 @@ async fn _sso_login(
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
}
|
||||
|
||||
async fn _password_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
ip: &ClientIp,
|
||||
client_version: Option<&ClientVersion>,
|
||||
) -> JsonResult {
|
||||
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Validate scope
|
||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||
|
||||
@@ -358,24 +376,14 @@ async fn _password_login(
|
||||
|
||||
// Get the user
|
||||
let username = data.username.as_ref().unwrap().trim();
|
||||
let log_username = username.escape_debug();
|
||||
let Some(mut user) = User::find_by_mail(username, conn).await else {
|
||||
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip))
|
||||
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip))
|
||||
};
|
||||
|
||||
// Set the user_id here to be passed back used for event logging.
|
||||
*user_id = Some(user.uuid.clone());
|
||||
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let password = data.password.as_ref().unwrap();
|
||||
|
||||
// If we get an auth request, we don't check the user's password, but the access code of the auth request
|
||||
@@ -383,7 +391,7 @@ async fn _password_login(
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
|
||||
err!(
|
||||
"Auth request not found. Try again.",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
@@ -395,13 +403,14 @@ async fn _password_login(
|
||||
|
||||
if auth_request.user_uuid != user.uuid
|
||||
|| !auth_request.approved.unwrap_or(false)
|
||||
|| auth_request.authentication_date.is_some()
|
||||
|| request_expired
|
||||
|| ip.ip.to_string() != auth_request.request_ip
|
||||
|| !auth_request.check_access_code(password)
|
||||
{
|
||||
err!(
|
||||
"Username or access code is incorrect. Try again",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
@@ -410,13 +419,24 @@ async fn _password_login(
|
||||
} else if !user.check_valid_password(password) {
|
||||
err!(
|
||||
"Username or password is incorrect. Try again",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||
if data.auth_request.is_none() {
|
||||
kdf_upgrade(&mut user, password, conn).await?;
|
||||
@@ -427,9 +447,9 @@ async fn _password_login(
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
if user.last_verifying_at.is_none()
|
||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||
> 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 {
|
||||
// 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...
|
||||
@@ -449,7 +469,7 @@ async fn _password_login(
|
||||
// We still want the login to fail until they actually verified the email address
|
||||
err!(
|
||||
"Please verify your email before trying again.",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
@@ -458,7 +478,20 @@ async fn _password_login(
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||
|
||||
// Like upstream, an auth request can be used for one login only. Marked after 2FA so a 2FA prompt doesn't use it up.
|
||||
if let Some(ref auth_request_id) = data.auth_request
|
||||
&& !AuthRequest::set_authentication_date(auth_request_id, conn).await?
|
||||
{
|
||||
err!(
|
||||
"Username or access code is incorrect. Try again",
|
||||
format!("IP: {}. Username: {log_username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
||||
|
||||
@@ -497,7 +530,7 @@ async fn authenticated_response(
|
||||
// Save to update `device.updated_at` to track usage and toggle new status
|
||||
device.save(true, conn).await?;
|
||||
|
||||
let master_password_policy = master_password_policy(user, conn).await;
|
||||
let master_password_policy = identity_master_password_policy(user, conn).await;
|
||||
|
||||
let has_master_password = !user.password_hash.is_empty();
|
||||
let master_password_unlock = if has_master_password {
|
||||
@@ -541,7 +574,6 @@ async fn authenticated_response(
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: Same as above
|
||||
"ForcePasswordReset": false,
|
||||
"MasterPasswordPolicy": master_password_policy,
|
||||
"scope": auth_tokens.scope(),
|
||||
@@ -561,23 +593,30 @@ async fn authenticated_response(
|
||||
result["TwoFactorToken"] = Value::String(token);
|
||||
}
|
||||
|
||||
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip);
|
||||
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
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
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
// Validate scope
|
||||
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::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
|
||||
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => {
|
||||
// Like upstream, only the user API key logs in a device
|
||||
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
user_api_key_login(data, user_id, conn, ip).await
|
||||
}
|
||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||
_ => err!("Scope not supported"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn _user_api_key_login(
|
||||
async fn user_api_key_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
@@ -695,7 +734,6 @@ async fn _user_api_key_login(
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||
"ForcePasswordReset": false,
|
||||
"scope": AuthMethod::UserApiKey.scope(),
|
||||
"AccountKeys": account_keys,
|
||||
@@ -709,13 +747,13 @@ async fn _user_api_key_login(
|
||||
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
|
||||
let client_id = data.client_id.as_ref().unwrap();
|
||||
let Some(org_id) = client_id.strip_prefix("organization.") else {
|
||||
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 {
|
||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
@@ -746,14 +784,13 @@ async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult
|
||||
let device_name = data.device_name.clone().expect("No device name provided");
|
||||
|
||||
// Find device or create new
|
||||
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||
Some(device) => Ok(device),
|
||||
None => {
|
||||
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||
// save device without updating `device.updated_at`
|
||||
device.save(false, conn).await?;
|
||||
Ok(device)
|
||||
}
|
||||
if let Some(device) = Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||
Ok(device)
|
||||
} else {
|
||||
let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||
// save device without updating `device.updated_at`
|
||||
device.save(false, conn).await?;
|
||||
Ok(device)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,7 +799,6 @@ async fn twofactor_auth(
|
||||
data: &ConnectData,
|
||||
device: &mut Device,
|
||||
ip: &ClientIp,
|
||||
client_version: Option<&ClientVersion>,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<Option<String>> {
|
||||
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
|
||||
@@ -779,7 +815,7 @@ async fn twofactor_auth(
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||
(tf.enabled && is_twofactor_provider_usable(&provider_type, Some(&tf.data))).then_some(tf.atype)
|
||||
})
|
||||
.collect();
|
||||
if twofactor_ids.is_empty() {
|
||||
@@ -787,59 +823,40 @@ 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
|
||||
// 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)
|
||||
&& !twofactor_ids.contains(&selected_id)
|
||||
{
|
||||
err_json!(
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||
"Invalid two factor provider"
|
||||
)
|
||||
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider")
|
||||
}
|
||||
|
||||
let twofactor_code = match data.two_factor_token {
|
||||
Some(ref code) => code,
|
||||
None => {
|
||||
err_json!(
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||
"2FA token not provided"
|
||||
)
|
||||
}
|
||||
// Like upstream, a blank token counts as not provided
|
||||
let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else {
|
||||
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided")
|
||||
};
|
||||
|
||||
let 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) {
|
||||
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::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
||||
Some(TwoFactorType::Duo) => {
|
||||
match CONFIG.duo_use_iframe() {
|
||||
true => {
|
||||
// Legacy iframe prompt flow
|
||||
duo::validate_duo_login(&user.email, twofactor_code, conn).await?
|
||||
}
|
||||
false => {
|
||||
// OIDC based flow
|
||||
duo_oidc::validate_duo_login(
|
||||
&user.email,
|
||||
twofactor_code,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
duo_oidc::validate_duo_login(
|
||||
&user.email,
|
||||
twofactor_code,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Some(TwoFactorType::Email) => {
|
||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?
|
||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
|
||||
}
|
||||
Some(TwoFactorType::Remember) => {
|
||||
match device.twofactor_remember {
|
||||
@@ -847,7 +864,7 @@ async fn twofactor_auth(
|
||||
// If it is invalid we need to trigger the 2FA Login prompt
|
||||
Some(ref token)
|
||||
if !CONFIG.disable_2fa_remember()
|
||||
&& (ct_eq(token, twofactor_code)
|
||||
&& (crypto::ct_eq(token, twofactor_code)
|
||||
&& auth::decode_2fa_remember(twofactor_code)
|
||||
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
|
||||
_ => {
|
||||
@@ -858,23 +875,42 @@ async fn twofactor_auth(
|
||||
device.save(true, conn).await?;
|
||||
}
|
||||
err_json!(
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
|
||||
json_err_twofactor(&twofactor_ids, user, data, conn).await?,
|
||||
"2FA Remember token not provided or expired"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(TwoFactorType::RecoveryCode) => {
|
||||
// Check if recovery code is correct
|
||||
if !user.check_valid_recovery_code(twofactor_code) {
|
||||
err!("Recovery code is incorrect. Try again.")
|
||||
// Like upstream, spaces and case don't matter
|
||||
let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase();
|
||||
if !user.check_valid_recovery_code(&recovery_code) {
|
||||
err!(
|
||||
"Two-step token is invalid. Try again.",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Remove all twofactors from the user
|
||||
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
||||
|
||||
// No device may keep skipping 2FA once every second factor is gone.
|
||||
// `device` is cleared in memory too, since saving it later would restore its token.
|
||||
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
|
||||
device.delete_twofactor_remember();
|
||||
|
||||
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
||||
|
||||
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||
log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||
|
||||
if CONFIG.mail_enabled()
|
||||
&& let Err(e) =
|
||||
mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await
|
||||
{
|
||||
error!("Error sending two-step login recovered email: {e:#?}");
|
||||
}
|
||||
|
||||
// Remove the recovery code, not needed without twofactors
|
||||
user.totp_recover = None;
|
||||
@@ -899,72 +935,45 @@ async fn twofactor_auth(
|
||||
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")
|
||||
}
|
||||
|
||||
async fn _json_err_twofactor(
|
||||
providers: &[i32],
|
||||
user_id: &UserId,
|
||||
data: &ConnectData,
|
||||
client_version: Option<&ClientVersion>,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<Value> {
|
||||
async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult<Value> {
|
||||
let user_id = &user.uuid;
|
||||
let mut result = json!({
|
||||
"error" : "invalid_grant",
|
||||
"error_description" : "Two factor required.",
|
||||
"TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(),
|
||||
"TwoFactorProviders2" : {}, // { "0" : null }
|
||||
"MasterPasswordPolicy": {
|
||||
"Object": "masterPasswordPolicy"
|
||||
}
|
||||
"MasterPasswordPolicy": identity_master_password_policy(user, conn).await,
|
||||
});
|
||||
|
||||
for provider in providers {
|
||||
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
||||
|
||||
match TwoFactorType::from_i32(*provider) {
|
||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||
|
||||
Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
|
||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||
}
|
||||
|
||||
Some(TwoFactorType::Duo) => {
|
||||
let email = match User::find_by_uuid(user_id, conn).await {
|
||||
Some(u) => u.email,
|
||||
None => err!("User does not exist"),
|
||||
};
|
||||
let auth_url = duo_oidc::get_duo_auth_url(
|
||||
&user.email,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match CONFIG.duo_use_iframe() {
|
||||
true => {
|
||||
// Legacy iframe prompt flow
|
||||
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"Host": host,
|
||||
"Signature": signature,
|
||||
})
|
||||
}
|
||||
false => {
|
||||
// OIDC based flow
|
||||
let auth_url = duo_oidc::get_duo_auth_url(
|
||||
&email,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"AuthUrl": auth_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"AuthUrl": auth_url,
|
||||
});
|
||||
}
|
||||
|
||||
Some(tf_type @ TwoFactorType::YubiKey) => {
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
|
||||
err!("No YubiKey devices registered")
|
||||
};
|
||||
|
||||
@@ -972,34 +981,35 @@ async fn _json_err_twofactor(
|
||||
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"Nfc": yubikey_metadata.nfc,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Some(tf_type @ TwoFactorType::Email) => {
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else {
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else {
|
||||
err!("No twofactor email registered")
|
||||
};
|
||||
|
||||
// Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`.
|
||||
let disabled_send = if let Some(cv) = client_version {
|
||||
let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap();
|
||||
ver_match.matches(&cv.0)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Send email immediately if email is the only 2FA option.
|
||||
if providers.len() == 1 && !disabled_send {
|
||||
email::send_token(user_id, conn).await?
|
||||
}
|
||||
|
||||
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"Email": email::obscure_email(&email_data.email),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
_ => {}
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,18 +1017,13 @@ async fn _json_err_twofactor(
|
||||
}
|
||||
|
||||
#[post("/accounts/prelogin", data = "<data>")]
|
||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
_prelogin(data, conn).await
|
||||
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||
prelogin(data, ip, conn).await
|
||||
}
|
||||
|
||||
#[post("/accounts/prelogin/password", data = "<data>")]
|
||||
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
_prelogin(data, conn).await
|
||||
}
|
||||
|
||||
#[post("/accounts/register", data = "<data>")]
|
||||
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, false, conn).await
|
||||
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||
prelogin(data, ip, conn).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1034,13 +1039,23 @@ enum RegisterVerificationResponse {
|
||||
#[response(status = 204)]
|
||||
NoContent(()),
|
||||
Token(Json<String>),
|
||||
PlainToken(String),
|
||||
}
|
||||
|
||||
// Return JSON only when the client explicitly requests it, otherwise return plain text.
|
||||
fn accepts_json(accept: Option<&Accept>) -> bool {
|
||||
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
|
||||
}
|
||||
|
||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||
async fn register_verification_email(
|
||||
data: Json<RegisterVerificationData>,
|
||||
accept: Option<&Accept>,
|
||||
ip: ClientIp,
|
||||
conn: DbConn,
|
||||
) -> ApiResult<RegisterVerificationResponse> {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
let data = data.into_inner();
|
||||
|
||||
// the registration can only continue if signup is allowed or there exists an invitation
|
||||
@@ -1057,13 +1072,13 @@ async fn register_verification_email(
|
||||
|
||||
if should_send_mail {
|
||||
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
|
||||
// paths that send mail take noticeably longer than ones that 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 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;
|
||||
} else {
|
||||
mail::send_register_verify_email(&data.email, &token).await?;
|
||||
@@ -1073,13 +1088,18 @@ async fn register_verification_email(
|
||||
} else {
|
||||
// If email verification is not required, return the token directly
|
||||
// the clients will use this token to finish the registration
|
||||
Ok(RegisterVerificationResponse::Token(Json(token)))
|
||||
Ok(if accepts_json(accept) {
|
||||
RegisterVerificationResponse::Token(Json(token))
|
||||
} else {
|
||||
RegisterVerificationResponse::PlainToken(token)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/accounts/register/finish", data = "<data>")]
|
||||
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, true, conn).await
|
||||
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
register(data, conn).await
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||
@@ -1138,11 +1158,15 @@ struct ConnectData {
|
||||
|
||||
// Needed for authorization code
|
||||
#[field(name = uncased("code"))]
|
||||
code: Option<OIDCState>,
|
||||
code: Option<OIDCCode>,
|
||||
#[field(name = uncased("code_verifier"))]
|
||||
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() {
|
||||
err!(msg)
|
||||
}
|
||||
@@ -1165,19 +1189,12 @@ const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING";
|
||||
|
||||
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||
async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
|
||||
_oidcsignin_redirect(
|
||||
state,
|
||||
OIDCCodeWrapper::Ok {
|
||||
code,
|
||||
},
|
||||
cookies,
|
||||
&mut conn,
|
||||
)
|
||||
.await
|
||||
oidcsignin_redirect(state, code, None, cookies, &mut conn).await
|
||||
}
|
||||
|
||||
// Bitwarden client appear to only care for code and state so we pipe it through
|
||||
// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141
|
||||
// Bitwarden client appear to only care for code and state
|
||||
// We save the error in the database and set the encoded state as the code to be able to retrieve them later on
|
||||
// cf: https://github.com/bitwarden/clients/blob/afd36d290ce18fb0048e0575e7d5a8f78b5dbffc/libs/auth/src/angular/sso/sso.component.ts#L156
|
||||
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
|
||||
async fn oidcsignin_error(
|
||||
state: String,
|
||||
@@ -1186,12 +1203,13 @@ async fn oidcsignin_error(
|
||||
cookies: &CookieJar<'_>,
|
||||
mut conn: DbConn,
|
||||
) -> ApiResult<Redirect> {
|
||||
_oidcsignin_redirect(
|
||||
state,
|
||||
OIDCCodeWrapper::Error {
|
||||
oidcsignin_redirect(
|
||||
state.clone(),
|
||||
state.into(),
|
||||
Some(OIDCCodeResponseError {
|
||||
error,
|
||||
error_description,
|
||||
},
|
||||
}),
|
||||
cookies,
|
||||
&mut conn,
|
||||
)
|
||||
@@ -1201,30 +1219,32 @@ async fn oidcsignin_error(
|
||||
// The state was encoded using Base64 to ensure no issue with providers.
|
||||
// iss and scope parameters are needed for redirection to work on IOS.
|
||||
// We pass the state as the code to get it back later on.
|
||||
async fn _oidcsignin_redirect(
|
||||
async fn oidcsignin_redirect(
|
||||
base64_state: String,
|
||||
code_response: OIDCCodeWrapper,
|
||||
code: OIDCCode,
|
||||
error: Option<OIDCCodeResponseError>,
|
||||
cookies: &CookieJar<'_>,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<Redirect> {
|
||||
let state = sso::decode_state(&base64_state)?;
|
||||
|
||||
let mut sso_auth = match SsoAuth::find(&state, conn).await {
|
||||
None => err!(format!("Cannot retrieve sso_auth for {state}")),
|
||||
Some(sso_auth) => sso_auth,
|
||||
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
|
||||
err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug()))
|
||||
};
|
||||
|
||||
// Browser-binding check
|
||||
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
|
||||
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_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()));
|
||||
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
|
||||
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
|
||||
_ => err!(format!("SSO session binding mismatch for {state}")),
|
||||
_ => err!(format!("SSO session binding mismatch for {}", state.escape_debug())),
|
||||
}
|
||||
cookies.remove(Cookie::build(SSO_BINDING_COOKIE).path("/identity/connect/").build());
|
||||
cookies
|
||||
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
|
||||
|
||||
sso_auth.code_response = Some(code_response);
|
||||
sso_auth.code_response = Some(code.clone());
|
||||
sso_auth.code_response_error = error;
|
||||
sso_auth.updated_at = Utc::now().naive_utc();
|
||||
sso_auth.save(conn).await?;
|
||||
|
||||
@@ -1234,7 +1254,7 @@ async fn _oidcsignin_redirect(
|
||||
};
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("code", &state)
|
||||
.append_pair("code", &code)
|
||||
.append_pair("state", &state)
|
||||
.append_pair("scope", &AuthMethod::Sso.scope())
|
||||
.append_pair("iss", &CONFIG.domain());
|
||||
@@ -1294,7 +1314,7 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
||||
|
||||
cookies.add(
|
||||
Cookie::build((SSO_BINDING_COOKIE, binding_token))
|
||||
.path("/identity/connect/")
|
||||
.path(format!("{}/identity/connect/", CONFIG.domain_path()))
|
||||
.max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds()))
|
||||
.same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN
|
||||
.http_only(true)
|
||||
|
||||
+27
-6
@@ -30,13 +30,15 @@ pub use crate::api::{
|
||||
},
|
||||
web::catchers as web_catchers,
|
||||
web::routes as web_routes,
|
||||
web::static_files,
|
||||
web::{invalidate_css_cache, static_files},
|
||||
};
|
||||
use crate::db::{
|
||||
models::{OrgPolicy, OrgPolicyType, User},
|
||||
DbConn,
|
||||
use crate::{
|
||||
CONFIG,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{OrgPolicy, OrgPolicyType, User},
|
||||
},
|
||||
};
|
||||
use crate::CONFIG;
|
||||
|
||||
// Type aliases for API methods results
|
||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||
@@ -44,7 +46,7 @@ pub type JsonResult = ApiResult<Json<Value>>;
|
||||
pub type EmptyResult = ApiResult<()>;
|
||||
|
||||
// Common structs representing JSON data received
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PasswordOrOtpData {
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
@@ -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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MasterPasswordPolicy {
|
||||
@@ -121,3 +124,21 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
|
||||
mpp_json["Object"] = json!("masterPasswordPolicy");
|
||||
mpp_json
|
||||
}
|
||||
|
||||
// Identity responses use PascalCase keys like upstream, some clients (Android) only read those
|
||||
async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value {
|
||||
match master_password_policy(user, conn).await {
|
||||
Value::Object(policy) => Value::Object(
|
||||
policy
|
||||
.into_iter()
|
||||
.map(|(mut key, value)| {
|
||||
if let Some(first) = key.get_mut(..1) {
|
||||
first.make_ascii_uppercase();
|
||||
}
|
||||
(key, value)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
policy => policy,
|
||||
}
|
||||
}
|
||||
+104
-25
@@ -1,22 +1,27 @@
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use rmpv::Value;
|
||||
use rocket::{futures::StreamExt, Route};
|
||||
use rocket::{Route, futures::StreamExt};
|
||||
use rocket_ws::{Message, WebSocket};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::{
|
||||
CONFIG, Error,
|
||||
auth::{ClientIp, WsAccessTokenHeader},
|
||||
db::{
|
||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||
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(|| {
|
||||
@@ -28,13 +33,21 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||
Arc::new(AnonymousWebSocketSubscriptions {
|
||||
map: Arc::new(dashmap::DashMap::new()),
|
||||
connections: Arc::new(dashmap::DashMap::new()),
|
||||
})
|
||||
});
|
||||
|
||||
use super::{
|
||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||
push_send_update, push_user_update,
|
||||
};
|
||||
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
|
||||
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||
|
||||
/// How often a Ping is sent to the client.
|
||||
const WS_PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Close the connection if nothing, not even a Pong, was received from the client for this long.
|
||||
/// Otherwise half-open connections (client gone without a FIN, e.g. behind a proxy or NAT) are kept forever.
|
||||
/// Same as the default `ClientTimeoutInterval` of ASP.NET Core SignalR, which the official server uses.
|
||||
const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||
|
||||
@@ -82,14 +95,21 @@ impl Drop for WSEntryMapGuard {
|
||||
struct WSAnonymousEntryMapGuard {
|
||||
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||
token: String,
|
||||
entry_uuid: uuid::Uuid,
|
||||
addr: IpAddr,
|
||||
}
|
||||
|
||||
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 {
|
||||
subscriptions,
|
||||
token,
|
||||
entry_uuid,
|
||||
addr,
|
||||
}
|
||||
}
|
||||
@@ -98,11 +118,15 @@ impl WSAnonymousEntryMapGuard {
|
||||
impl Drop for WSAnonymousEntryMapGuard {
|
||||
fn drop(&mut self) {
|
||||
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..>")]
|
||||
fn websockets_hub<'r>(
|
||||
ws: WebSocket,
|
||||
@@ -140,12 +164,16 @@ fn websockets_hub<'r>(
|
||||
rocket_ws::Stream! { ws => {
|
||||
let mut ws = ws;
|
||||
let _guard = guard;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
||||
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
|
||||
let mut last_received = Instant::now();
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = ws.next() => {
|
||||
match res {
|
||||
Some(Ok(message)) => {
|
||||
// Any message, including a Pong, means the client is still there
|
||||
last_received = Instant::now();
|
||||
|
||||
match message {
|
||||
// Respond to any pings
|
||||
Message::Ping(ping) => yield Message::Pong(ping),
|
||||
@@ -179,14 +207,20 @@ fn websockets_hub<'r>(
|
||||
}
|
||||
}
|
||||
|
||||
_ = interval.tick() => yield Message::Ping(create_ping())
|
||||
_ = interval.tick() => {
|
||||
// The client stopped responding without closing the connection, drop it
|
||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
yield Message::Ping(create_ping());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(tail_expr_drop_order)]
|
||||
#[expect(tail_expr_drop_order)]
|
||||
#[get("/anonymous-hub?<token..>")]
|
||||
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);
|
||||
@@ -194,24 +228,35 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
||||
let (mut rx, guard) = {
|
||||
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);
|
||||
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
|
||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
|
||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
|
||||
};
|
||||
|
||||
Ok({
|
||||
rocket_ws::Stream! { ws => {
|
||||
let mut ws = ws;
|
||||
let _guard = guard;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
||||
let mut interval = tokio::time::interval(WS_PING_INTERVAL);
|
||||
let mut last_received = Instant::now();
|
||||
loop {
|
||||
tokio::select! {
|
||||
res = ws.next() => {
|
||||
match res {
|
||||
Some(Ok(message)) => {
|
||||
// Any message, including a Pong, means the client is still there
|
||||
last_received = Instant::now();
|
||||
|
||||
match message {
|
||||
// Respond to any pings
|
||||
Message::Ping(ping) => yield Message::Pong(ping),
|
||||
@@ -245,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
||||
}
|
||||
}
|
||||
|
||||
_ = interval.tick() => yield Message::Ping(create_ping())
|
||||
_ = interval.tick() => {
|
||||
// The client stopped responding without closing the connection, drop it
|
||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||
break;
|
||||
}
|
||||
yield Message::Ping(create_ping());
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -268,14 +319,15 @@ fn serialize(val: &Value) -> Vec<u8> {
|
||||
let mut len_buf: Vec<u8> = Vec::new();
|
||||
|
||||
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;
|
||||
|
||||
if size > 0 {
|
||||
size_part |= 0x80;
|
||||
}
|
||||
|
||||
len_buf.push(size_part as u8);
|
||||
len_buf.push(size_part);
|
||||
|
||||
if size == 0 {
|
||||
break;
|
||||
@@ -329,7 +381,7 @@ pub struct WebSocketUsers {
|
||||
impl WebSocketUsers {
|
||||
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()) {
|
||||
for (_, sender) in user.iter() {
|
||||
for (_, sender) in &user {
|
||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||
error!("Error sending WS update {e}");
|
||||
}
|
||||
@@ -533,12 +585,39 @@ impl WebSocketUsers {
|
||||
|
||||
#[derive(Clone)]
|
||||
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 {
|
||||
/// 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]) {
|
||||
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 {
|
||||
error!("Error sending WS update {e}");
|
||||
}
|
||||
@@ -582,7 +661,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
||||
V::Nil,
|
||||
"ReceiveMessage".into(),
|
||||
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()),
|
||||
("Payload".into(), payload.into()),
|
||||
])]),
|
||||
|
||||
+24
-24
@@ -4,21 +4,21 @@ use std::{
|
||||
};
|
||||
|
||||
use reqwest::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method,
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, UpdateType},
|
||||
db::{
|
||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||
DbConn,
|
||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||
},
|
||||
http_client::make_http_request,
|
||||
util::{format_date, get_uuid},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -74,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
||||
};
|
||||
|
||||
let mut api_token = API_TOKEN.write().await;
|
||||
api_token.valid_until = Instant::now()
|
||||
.checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
|
||||
.unwrap();
|
||||
// Token valid for half the specified time
|
||||
let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned());
|
||||
api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap();
|
||||
|
||||
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.
|
||||
if cipher.organization_uuid.is_some() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
let Some(user_id) = &cipher.user_uuid else {
|
||||
debug!("Cipher has no uuid");
|
||||
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) {
|
||||
if let Some(s) = &send.user_uuid {
|
||||
if Device::check_user_has_push_device(s, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
if let Some(s) = &send.user_uuid
|
||||
&& Device::check_user_has_push_device(s, conn).await
|
||||
{
|
||||
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,
|
||||
"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,
|
||||
"revisionDate": format_date(&send.revision_date)
|
||||
},
|
||||
"clientType": null,
|
||||
"installationId": null
|
||||
})));
|
||||
}
|
||||
"revisionDate": format_date(&send.revision_date)
|
||||
},
|
||||
"clientType": null,
|
||||
"installationId": null
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ async fn send_to_push_relay(notification_data: Value) {
|
||||
.await
|
||||
{
|
||||
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) {
|
||||
|
||||
+78
-19
@@ -1,21 +1,28 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
Catcher, Route,
|
||||
fs::NamedFile,
|
||||
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,
|
||||
Catcher, Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{core::now, ApiResult, EmptyResult},
|
||||
auth::decode_file_download,
|
||||
db::models::{AttachmentId, CipherId},
|
||||
error::Error,
|
||||
util::Cached,
|
||||
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> {
|
||||
@@ -23,12 +30,20 @@ pub fn routes() -> Vec<Route> {
|
||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||
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)]
|
||||
if CONFIG.reload_templates() {
|
||||
routes.append(&mut routes![_static_files_dev]);
|
||||
routes.append(&mut routes![static_files_dev]);
|
||||
}
|
||||
|
||||
routes
|
||||
@@ -52,9 +67,29 @@ fn not_found() -> ApiResult<Html<String>> {
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
struct CssCache {
|
||||
css: String,
|
||||
etag: String,
|
||||
}
|
||||
|
||||
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
|
||||
|
||||
pub fn invalidate_css_cache() {
|
||||
*CSS_CACHE.write().unwrap() = None;
|
||||
}
|
||||
|
||||
#[get("/css/vaultwarden.css")]
|
||||
fn vaultwarden_css() -> 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!({
|
||||
"email_change_allowed": CONFIG.email_change_allowed(),
|
||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||
"load_user_scss": true,
|
||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||
@@ -101,8 +136,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
||||
}
|
||||
};
|
||||
|
||||
// Cache for one day should be enough and not too much
|
||||
Cached::ttl(Css(css), 86_400, false)
|
||||
let etag = sha256_hex(css.as_bytes());
|
||||
let cached = Arc::new(CssCache {
|
||||
css,
|
||||
etag,
|
||||
});
|
||||
|
||||
if !CONFIG.reload_templates() {
|
||||
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
|
||||
}
|
||||
|
||||
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
@@ -160,6 +205,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
|
||||
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||
@@ -178,7 +241,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.
|
||||
use crate::db::DbConn;
|
||||
#[get("/alive")]
|
||||
fn alive(_conn: DbConn) -> Json<String> {
|
||||
now()
|
||||
@@ -197,7 +259,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||
#[cfg(debug_assertions)]
|
||||
#[get("/vw_static/<filename>", rank = 1)]
|
||||
pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
warn!("LOADING STATIC FILES FROM DISK");
|
||||
let file = filename.to_str().unwrap_or_default();
|
||||
let ext = filename.extension().unwrap_or_default();
|
||||
@@ -210,7 +272,7 @@ pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||
|
||||
if let Ok(path) = path {
|
||||
return NamedFile::open(path).await.ok();
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -240,9 +302,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||
"jquery-4.0.0.slim.js" => {
|
||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
||||
}
|
||||
_ => err!(format!("Static file not found: {filename}")),
|
||||
}
|
||||
}
|
||||
+233
-110
@@ -1,3 +1,11 @@
|
||||
#[path = "auth/two_factor.rs"]
|
||||
pub mod two_factor;
|
||||
|
||||
#[path = "auth/send.rs"]
|
||||
pub mod send;
|
||||
pub type SendTokens = send::SendTokens;
|
||||
pub type SendHeaders = send::SendHeaders;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
net::IpAddr,
|
||||
@@ -5,21 +13,32 @@ use std::{
|
||||
};
|
||||
|
||||
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 openssl::rsa::Rsa;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use ring::hmac;
|
||||
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||
|
||||
use rocket::{
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::ApiResult,
|
||||
CONFIG,
|
||||
api::{ApiResult, core::log_event},
|
||||
config::PathType,
|
||||
db::models::{
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, UserId,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, User, UserId, UserStampException,
|
||||
},
|
||||
},
|
||||
error::Error,
|
||||
sso, CONFIG,
|
||||
sso,
|
||||
};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
@@ -50,18 +69,15 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
|
||||
|
||||
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
||||
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||
static PRELOGIN_KDF_KEY: OnceLock<hmac::Key> = OnceLock::new();
|
||||
|
||||
pub async fn initialize_keys() -> Result<(), Error> {
|
||||
use std::io::Error;
|
||||
use std::io::Error as IoError;
|
||||
|
||||
let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key())
|
||||
.file_name()
|
||||
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
||||
.to_string();
|
||||
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
||||
.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 {
|
||||
Ok(buffer) => Some(buffer),
|
||||
@@ -88,9 +104,22 @@ pub async fn initialize_keys() -> Result<(), Error> {
|
||||
if PUBLIC_RSA_KEY.set(dec).is_err() {
|
||||
err!("PUBLIC_RSA_KEY must only be initialized once")
|
||||
}
|
||||
|
||||
// Derived from the RSA key, so it stays the same across restarts without storing another secret
|
||||
let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf");
|
||||
if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() {
|
||||
err!("PRELOGIN_KDF_KEY must only be initialized once")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns an index in `0..len` derived from the email, the same for every request
|
||||
pub fn prelogin_kdf_index(email: &str, len: usize) -> usize {
|
||||
let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes());
|
||||
let tag = tag.as_ref();
|
||||
usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len
|
||||
}
|
||||
|
||||
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
|
||||
Ok(token) => token,
|
||||
@@ -230,7 +259,7 @@ impl LoginJwtClaims {
|
||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
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
|
||||
@@ -242,7 +271,7 @@ impl LoginJwtClaims {
|
||||
premium: true,
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
||||
email_verified: user.verified(),
|
||||
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
@@ -257,7 +286,7 @@ impl LoginJwtClaims {
|
||||
sstamp: user.security_stamp.clone(),
|
||||
device: device.uuid.clone(),
|
||||
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,
|
||||
amr: vec!["Application".into()],
|
||||
}
|
||||
@@ -411,7 +440,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId)
|
||||
let time_now = Utc::now();
|
||||
FileDownloadClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(),
|
||||
iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
|
||||
sub: cipher_id,
|
||||
file_id,
|
||||
@@ -482,6 +511,16 @@ pub struct BasicJwtClaims {
|
||||
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 {
|
||||
let time_now = Utc::now();
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
@@ -510,7 +549,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||
sub: "admin_panel".to_string(),
|
||||
sub: "admin_panel".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,16 +566,6 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC
|
||||
//
|
||||
// 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 host: String,
|
||||
}
|
||||
@@ -552,7 +581,7 @@ impl<'r> FromRequest<'r> for Host {
|
||||
let host = if CONFIG.domain_set() {
|
||||
CONFIG.domain()
|
||||
} else if let Some(referer) = headers.get_one("Referer") {
|
||||
referer.to_string()
|
||||
referer.to_owned()
|
||||
} else {
|
||||
// Try to guess from the headers
|
||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||
@@ -588,13 +617,15 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
||||
let device_type: i32 =
|
||||
request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14);
|
||||
// When unknown or unable to parse, return 'UnknownBrowser'
|
||||
let device_type: i32 = request
|
||||
.headers()
|
||||
.get_one("device-type")
|
||||
.and_then(|d| d.parse().ok())
|
||||
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||
|
||||
Outcome::Success(ClientHeaders {
|
||||
device_type,
|
||||
@@ -618,18 +649,19 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
let headers = request.headers();
|
||||
|
||||
let host = try_outcome!(Host::from_request(request).await).host;
|
||||
let ip = match ClientIp::from_request(request).await {
|
||||
Outcome::Success(ip) => ip,
|
||||
_ => err_handler!("Error getting Client IP"),
|
||||
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||
err_handler!("Error getting Client IP")
|
||||
};
|
||||
|
||||
// Get access_token
|
||||
let access_token: &str = match headers.get_one("Authorization") {
|
||||
Some(a) => match a.rsplit("Bearer ").next() {
|
||||
Some(split) => split,
|
||||
None => err_handler!("No access token provided"),
|
||||
},
|
||||
None => err_handler!("No access token provided"),
|
||||
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 device and user from it
|
||||
@@ -640,9 +672,8 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
let device_id = claims.device;
|
||||
let user_id = claims.sub;
|
||||
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
||||
@@ -673,7 +704,7 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
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")
|
||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||
err_handler!("Invalid security stamp for matched stamp exception")
|
||||
@@ -761,9 +792,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
|
||||
match url_org_id {
|
||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
let user = headers.user;
|
||||
@@ -810,6 +840,12 @@ pub struct AdminHeaders {
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
impl AdminHeaders {
|
||||
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
|
||||
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -835,16 +871,16 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
// 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.
|
||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id.into());
|
||||
}
|
||||
if let Some(Ok(col_id)) = request.param::<String>(3)
|
||||
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||
{
|
||||
return Some(col_id.into());
|
||||
}
|
||||
|
||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id.into());
|
||||
}
|
||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId")
|
||||
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||
{
|
||||
return Some(col_id.into());
|
||||
}
|
||||
|
||||
None
|
||||
@@ -868,18 +904,16 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.is_confirmed_and_manager() {
|
||||
match get_col_id(request) {
|
||||
Some(col_id) => {
|
||||
let conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
if let Some(col_id) = get_col_id(request) {
|
||||
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||
err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
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")
|
||||
}
|
||||
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!("Error getting the collection id"),
|
||||
} else {
|
||||
err_handler!("Error getting the collection id")
|
||||
}
|
||||
|
||||
Outcome::Success(Self {
|
||||
@@ -1040,31 +1074,85 @@ impl From<OrgMemberHeaders> for Headers {
|
||||
//
|
||||
// Client IP address detection
|
||||
//
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct ClientIp {
|
||||
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))
|
||||
}
|
||||
|
||||
/// Each proxy appends the address it received the request from to a list header (`X-Forwarded-For: client, proxy1`).
|
||||
/// Like ASP.NET's ForwardedHeaders, walk the entries of every occurrence of the header from the right and take the
|
||||
/// first one that isn't a trusted proxy. If they all are, the leftmost one is the client.
|
||||
fn client_ip_from_header(req: &Request<'_>, header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
|
||||
let values: Vec<&str> = req.headers().get(header).collect();
|
||||
client_ip_from_header_values(&values, header, is_trusted)
|
||||
}
|
||||
|
||||
fn client_ip_from_header_values(values: &[&str], header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
|
||||
let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect();
|
||||
|
||||
let mut client_ip = None;
|
||||
for entry in entries.into_iter().rev() {
|
||||
let Ok(ip) = entry.parse() else {
|
||||
warn!("'{header}' header is malformed: {entry}");
|
||||
break;
|
||||
};
|
||||
client_ip = Some(ip);
|
||||
if !is_trusted(ip) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
client_ip
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for ClientIp {
|
||||
type Error = ();
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let ip = if CONFIG._ip_header_enabled() {
|
||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||
match ip.find(',') {
|
||||
Some(idx) => &ip[..idx],
|
||||
None => ip,
|
||||
}
|
||||
.parse()
|
||||
.map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header()))
|
||||
.ok()
|
||||
})
|
||||
let remote = req.remote().map(|r| r.ip());
|
||||
|
||||
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
|
||||
client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip)))
|
||||
} 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
|
||||
};
|
||||
|
||||
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 {
|
||||
ip,
|
||||
@@ -1072,6 +1160,7 @@ impl<'r> FromRequest<'r> for ClientIp {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Secure {
|
||||
pub https: bool,
|
||||
}
|
||||
@@ -1157,15 +1246,14 @@ pub enum AuthMethod {
|
||||
impl AuthMethod {
|
||||
pub fn scope(&self) -> String {
|
||||
match self {
|
||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
||||
AuthMethod::Password => "api offline_access".to_string(),
|
||||
AuthMethod::Sso => "api offline_access".to_string(),
|
||||
AuthMethod::UserApiKey => "api".to_string(),
|
||||
AuthMethod::OrgApiKey => "api.organization".to_owned(),
|
||||
AuthMethod::UserApiKey => "api".to_owned(),
|
||||
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -1173,7 +1261,7 @@ impl AuthMethod {
|
||||
match scope {
|
||||
None => err!("Missing scope"),
|
||||
Some(scope) if scope == &method_scope => Ok(method_scope),
|
||||
Some(scope) => err!(format!("Scope ({scope}) not supported")),
|
||||
Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1259,36 +1347,22 @@ pub async fn refresh_tokens(
|
||||
) -> ApiResult<(Device, AuthTokens)> {
|
||||
let refresh_claims = match decode_refresh(refresh_token) {
|
||||
Err(err) => {
|
||||
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
|
||||
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||
|
||||
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
|
||||
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
|
||||
// check expiration or issuer, so they're not included here.
|
||||
RefreshJwtClaims {
|
||||
nbf: 0,
|
||||
exp: 0,
|
||||
iss: String::new(),
|
||||
sub: AuthMethod::Password,
|
||||
device_token: refresh_token.into(),
|
||||
token: None,
|
||||
}
|
||||
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
|
||||
err_silent!("Invalid refresh token")
|
||||
}
|
||||
Ok(claims) => claims,
|
||||
};
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
||||
None => err!("Invalid refresh token"),
|
||||
Some(device) => device,
|
||||
let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else {
|
||||
err!("Invalid refresh token")
|
||||
};
|
||||
|
||||
// Save to update `updated_at`.
|
||||
device.save(true, conn).await?;
|
||||
|
||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||
None => err!("Impossible to find user"),
|
||||
Some(user) => user,
|
||||
let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else {
|
||||
err!("Impossible to find user")
|
||||
};
|
||||
|
||||
let auth_tokens = match refresh_claims.sub {
|
||||
@@ -1306,3 +1380,52 @@ pub async fn refresh_tokens(
|
||||
|
||||
Ok((device, auth_tokens))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn client_ip(values: &[&str]) -> Option<IpAddr> {
|
||||
let is_trusted = |ip: IpAddr| match ip {
|
||||
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
|
||||
IpAddr::V6(v6) => v6.is_loopback(),
|
||||
};
|
||||
client_ip_from_header_values(values, "X-Forwarded-For", is_trusted)
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> Option<IpAddr> {
|
||||
Some(s.parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_header_single_value() {
|
||||
assert_eq!(client_ip(&["203.0.113.5"]), ip("203.0.113.5"));
|
||||
assert_eq!(client_ip(&["10.0.0.7"]), ip("10.0.0.7"));
|
||||
assert_eq!(client_ip(&["2001:db8::1"]), ip("2001:db8::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_header_uses_rightmost_untrusted_entry() {
|
||||
// Entries already in the request come before the one the proxy appended
|
||||
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5"]), ip("203.0.113.5"));
|
||||
// Trusted proxies on the right are skipped
|
||||
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5, 10.0.0.2, 127.0.0.1"]), ip("203.0.113.5"));
|
||||
// All trusted, so the leftmost entry is the client
|
||||
assert_eq!(client_ip(&["192.168.1.10, 10.0.0.2"]), ip("192.168.1.10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_header_reads_every_occurrence() {
|
||||
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5"]), ip("203.0.113.5"));
|
||||
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5, 10.0.0.2"]), ip("203.0.113.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_header_malformed_entries() {
|
||||
assert_eq!(client_ip(&[]), None);
|
||||
assert_eq!(client_ip(&[""]), None);
|
||||
assert_eq!(client_ip(&["203.0.113.5, not-an-ip"]), None);
|
||||
// Stops at the malformed entry and keeps the closest proxy
|
||||
assert_eq!(client_ip(&["not-an-ip, 10.0.0.2"]), ip("10.0.0.2"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user