mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-08-23 09:07:47 +03:00
Compare commits
134
Commits
07569a06da
..
1.37.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f21a3adae2 | ||
|
|
07aa377af7 | ||
|
|
14258caec9 | ||
|
|
cb46fcb948 | ||
|
|
b89648a136 | ||
|
|
c3bd1eb565 | ||
|
|
8c3c969938 | ||
|
|
38a6850b8d | ||
|
|
d297e274a3 | ||
|
|
a354e57659 | ||
|
|
5cc7360816 | ||
|
|
62748100f0 | ||
|
|
fcbdebd6d7 | ||
|
|
454b8e2a35 | ||
|
|
7883da554e | ||
|
|
fd2b6528a9 | ||
|
|
cc57e60886 | ||
|
|
e5681258f0 | ||
|
|
7cf0c5d67e | ||
|
|
b04ed75f9f | ||
|
|
0ed8ab68f7 | ||
|
|
dfebee57ec | ||
|
|
bfe420a018 | ||
|
|
e7e4b9a86d | ||
|
|
bb549986e6 | ||
|
|
39954af96a | ||
|
|
a6b43651ca | ||
|
|
3f28b583db | ||
|
|
d4f67429d6 | ||
|
|
fc43737868 | ||
|
|
43df0fb7f4 | ||
|
|
d29cd29f55 | ||
|
|
2811df2953 | ||
|
|
8f0e99b875 | ||
|
|
f07a91141a | ||
|
|
787822854c | ||
|
|
f62a7a66c8 | ||
|
|
3a1378f469 | ||
|
|
dde63e209e | ||
|
|
235cf88231 | ||
|
|
c0a78dd55a | ||
|
|
711bb53d3d | ||
|
|
650defac75 | ||
|
|
2b3736802d | ||
|
|
9c7df6412c | ||
|
|
065c1f2cd5 | ||
|
|
1a1d7f578a | ||
|
|
2b16a05e54 | ||
|
|
c6e9948984 | ||
|
|
ecdb18fcde | ||
|
|
df25d316d6 | ||
|
|
747286dccd | ||
|
|
e60105411b | ||
|
|
937857a0bc | ||
|
|
ba55191676 | ||
|
|
c555f7d198 | ||
|
|
74819b95bd | ||
|
|
da2af3d362 | ||
|
|
1583fe4af3 | ||
|
|
36f0620fd1 | ||
|
|
3cd2d4afe7 | ||
|
|
d09c45bb63 | ||
|
|
feecfb20da | ||
|
|
347279a12c | ||
|
|
7f65a254b3 | ||
|
|
cc80f689ed | ||
|
|
4737192853 | ||
|
|
0c6817cb4e | ||
|
|
25a71d913f | ||
|
|
b2cd556f3e | ||
|
|
4352fffeec | ||
|
|
8d08697cf8 | ||
|
|
9f1df42259 | ||
|
|
1e1f9957cd | ||
|
|
bf37657c08 | ||
|
|
3e2cef7e8b | ||
|
|
2af9d21158 | ||
|
|
c4f6c4e63b | ||
|
|
eb2a56aea1 | ||
|
|
a4907f3539 | ||
|
|
8801b47d80 | ||
|
|
1ae9dc4119 | ||
|
|
02377eeac8 | ||
|
|
d9c75508c2 | ||
|
|
0ab7784b06 | ||
|
|
5c91058ba0 | ||
|
|
229b58fe4e | ||
|
|
061d320c7f | ||
|
|
2c73c6c2f2 | ||
|
|
b920caf285 | ||
|
|
57bdab1550 | ||
|
|
b77c01b8bb | ||
|
|
9cca120fb3 | ||
|
|
4ad8baf7be | ||
|
|
8f689d8795 | ||
|
|
2d91a9460b | ||
|
|
e81e6a5060 | ||
|
|
76d0856bbe | ||
|
|
f0e79fd391 | ||
|
|
5981705375 |
+48
-17
@@ -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
|
||||
@@ -183,9 +184,9 @@
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||
#
|
||||
## Cron schedule of the job that cleans sso nonce from incomplete flow
|
||||
## Cron schedule of the job that cleans sso auth from incomplete flow
|
||||
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
||||
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
|
||||
# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
|
||||
|
||||
########################
|
||||
### General settings ###
|
||||
@@ -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,14 @@
|
||||
## 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.
|
||||
# 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 `{}`,
|
||||
@@ -348,7 +365,7 @@
|
||||
## Default: 2592000 (30 days)
|
||||
# ICON_CACHE_TTL=2592000
|
||||
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
||||
## Default: 2592000 (3 days)
|
||||
## Default: 259200 (3 days)
|
||||
# ICON_CACHE_NEGTTL=259200
|
||||
|
||||
## Icon download timeout
|
||||
@@ -372,16 +389,23 @@
|
||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||
##
|
||||
## The following flags are available:
|
||||
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
|
||||
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
|
||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
|
||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
|
||||
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Needs desktop >= 2025.11.0)
|
||||
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
|
||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
|
||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
|
||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
|
||||
## - "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)
|
||||
## - "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)
|
||||
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
|
||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
|
||||
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
|
||||
## - "pm-30529-webauthn-related-origins":
|
||||
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||
|
||||
## Require new device emails. When a user logs in an email is required to be sent.
|
||||
## If sending the email fails the login attempt will fail!!
|
||||
@@ -453,6 +477,13 @@
|
||||
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
||||
# 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.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
# Ignore vendored scripts in GitHub stats
|
||||
src/static/scripts/* linguist-vendored
|
||||
|
||||
|
||||
+22
-24
@@ -1,6 +1,10 @@
|
||||
name: Build
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
@@ -30,6 +34,10 @@ on:
|
||||
- "docker/DockerSettings.yaml"
|
||||
- "macros/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test ${{ matrix.channel }}
|
||||
@@ -54,7 +62,7 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -63,7 +71,6 @@ jobs:
|
||||
# Determine rust-toolchain version
|
||||
- name: Init Variables
|
||||
id: toolchain
|
||||
shell: bash
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
run: |
|
||||
@@ -78,32 +85,23 @@ jobs:
|
||||
# End Determine rust-toolchain version
|
||||
|
||||
|
||||
# Only install the clippy and rustfmt components on the default rust-toolchain
|
||||
- name: "Install rust-toolchain version"
|
||||
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e # master @ Nov 20, 2025, 7:02 PM GMT+1
|
||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
components: clippy, rustfmt
|
||||
# End Uses the rust-toolchain file to determine version
|
||||
|
||||
|
||||
# Install the any other channel to be used for which we do not execute clippy and rustfmt
|
||||
- name: "Install MSRV version"
|
||||
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e # master @ Nov 20, 2025, 7:02 PM GMT+1
|
||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
# End Install the MSRV channel to be used
|
||||
|
||||
# Set the current matrix toolchain version as default
|
||||
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||
- name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||
env:
|
||||
CHANNEL: ${{ matrix.channel }}
|
||||
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||
run: |
|
||||
# Remove the rust-toolchain.toml
|
||||
rm rust-toolchain.toml
|
||||
# Set the default
|
||||
|
||||
# Install the correct toolchain version
|
||||
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update
|
||||
|
||||
# If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy
|
||||
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
||||
rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy
|
||||
fi
|
||||
|
||||
# Set as the default toolchain
|
||||
rustup default "${RUST_TOOLCHAIN}"
|
||||
|
||||
# Show environment
|
||||
@@ -115,7 +113,7 @@ jobs:
|
||||
|
||||
# Enable Rust Caching
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
name: Check templates
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
docker-templates:
|
||||
name: Validate docker templates
|
||||
@@ -12,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
name: Hadolint
|
||||
|
||||
on: [ push, pull_request ]
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
hadolint:
|
||||
@@ -13,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -23,31 +30,29 @@ jobs:
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
||||
- name: Download hadolint
|
||||
shell: bash
|
||||
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# Test Dockerfiles with hadolint
|
||||
- name: Run hadolint
|
||||
shell: bash
|
||||
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@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||
with:
|
||||
dockerfile: docker/Dockerfile.debian
|
||||
|
||||
- name: Run hadolint on Dockerfile.alpine
|
||||
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
|
||||
with:
|
||||
dockerfile: docker/Dockerfile.alpine
|
||||
# End Test Dockerfiles with hadolint
|
||||
|
||||
# Test Dockerfiles with docker build checks
|
||||
- name: Run docker build check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Checking docker/Dockerfile.debian"
|
||||
docker build --check . -f docker/Dockerfile.debian
|
||||
|
||||
+97
-126
@@ -1,6 +1,12 @@
|
||||
name: Release
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
# Apply concurrency control only on the upstream repo
|
||||
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||
# Don't cancel other runs when creating a tag
|
||||
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -10,33 +16,31 @@ on:
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
||||
- '[1-2].[0-9]+.[0-9]+'
|
||||
|
||||
concurrency:
|
||||
# Apply concurrency control only on the upstream repo
|
||||
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||
# Don't cancel other runs when creating a tag
|
||||
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
# The *_REPO variables need to be configured as repository variables
|
||||
# Append `/settings/variables/actions` to your repo url
|
||||
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
|
||||
# Check for Docker hub credentials in secrets
|
||||
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
||||
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
|
||||
# Check for Github credentials in secrets
|
||||
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
|
||||
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
|
||||
# Check for Quay.io credentials in secrets
|
||||
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
|
||||
# A "release" environment must be created in the repository settings
|
||||
# (Settings > Environments > New environment) with the following
|
||||
# variables and secrets configured as needed.
|
||||
#
|
||||
# Variables (only set the ones for registries you want to push to):
|
||||
# DOCKERHUB_REPO: 'index.docker.io/<user>/<repo>'
|
||||
# QUAY_REPO: 'quay.io/<user>/<repo>'
|
||||
# GHCR_REPO: 'ghcr.io/<user>/<repo>'
|
||||
#
|
||||
# Secrets (only required when the corresponding *_REPO variable is set):
|
||||
# DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
|
||||
# QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN
|
||||
# GITHUB_TOKEN is provided automatically
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build Vaultwarden containers
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
contents: read
|
||||
@@ -44,12 +48,6 @@ jobs:
|
||||
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||
runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 120
|
||||
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
|
||||
services:
|
||||
registry:
|
||||
image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
|
||||
ports:
|
||||
- 5000:5000
|
||||
env:
|
||||
SOURCE_COMMIT: ${{ github.sha }}
|
||||
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
||||
@@ -57,18 +55,16 @@ jobs:
|
||||
matrix:
|
||||
arch: ["amd64", "arm64", "arm/v7", "arm/v6"]
|
||||
base_image: ["debian","alpine"]
|
||||
outputs:
|
||||
base-tags: ${{ steps.determine-version.outputs.BASE_TAGS }}
|
||||
|
||||
steps:
|
||||
- name: Initialize QEMU binfmt support
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
with:
|
||||
platforms: "arm64,arm"
|
||||
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
@@ -81,7 +77,7 @@ jobs:
|
||||
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
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
|
||||
@@ -96,19 +92,9 @@ jobs:
|
||||
NORMALIZED_ARCH="${MATRIX_ARCH//\/}"
|
||||
echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Determine Base Tags and Source Version
|
||||
- name: Determine Base Tags and Source Version
|
||||
id: determine-version
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
# Determine Source Version
|
||||
- name: Determine Source Version
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_OUTPUT}"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
echo "BASE_TAGS=testing" | tee -a "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
# Get the Source Version for this release
|
||||
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
||||
@@ -117,18 +103,17 @@ jobs:
|
||||
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
||||
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
# End Determine Base Tags
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
|
||||
- name: Add registry for DockerHub
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||
run: |
|
||||
@@ -136,15 +121,15 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
|
||||
- name: Add registry for ghcr.io
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
run: |
|
||||
@@ -152,15 +137,15 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
|
||||
- name: Add registry for Quay.io
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
env:
|
||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||
run: |
|
||||
@@ -174,7 +159,7 @@ jobs:
|
||||
run: |
|
||||
#
|
||||
# Check if there is a GitHub Container Registry Login and use it for caching
|
||||
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
|
||||
if [[ -n "${GHCR_REPO}" ]]; then
|
||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||
else
|
||||
@@ -183,10 +168,6 @@ jobs:
|
||||
fi
|
||||
#
|
||||
|
||||
- name: Add localhost registry
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
- name: Generate tags
|
||||
id: tags
|
||||
env:
|
||||
@@ -204,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0
|
||||
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||
env:
|
||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -220,7 +201,8 @@ jobs:
|
||||
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
||||
*.platform=linux/${{ matrix.arch }}
|
||||
${{ env.TAGS }}
|
||||
*.output=type=image,push-by-digest=true,name-canonical=true,push=true,compression=zstd
|
||||
*.output=type=local,dest=./output
|
||||
*.output=type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Extract digest SHA
|
||||
env:
|
||||
@@ -240,73 +222,49 @@ jobs:
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Extract the Alpine binaries from the containers
|
||||
- name: Extract binaries
|
||||
- name: Rename binaries to match target platform
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
BASE_IMAGE: ${{ matrix.base_image }}
|
||||
DIGEST_SHA: ${{ env.DIGEST_SHA }}
|
||||
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
EXTRACT_TAG="latest"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
EXTRACT_TAG="testing"
|
||||
fi
|
||||
|
||||
# Check which base_image was used and append -alpine if needed
|
||||
if [[ "${BASE_IMAGE}" == "alpine" ]]; then
|
||||
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
|
||||
fi
|
||||
|
||||
CONTAINER_ID="$(docker create "localhost:5000/vaultwarden/server:${EXTRACT_TAG}@${DIGEST_SHA}")"
|
||||
|
||||
# Copy the binary
|
||||
docker cp "$CONTAINER_ID":/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
|
||||
|
||||
# Clean up
|
||||
docker rm "$CONTAINER_ID"
|
||||
mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
|
||||
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: Attest binaries
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
- name: Upload binaries as artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||
|
||||
merge-manifests:
|
||||
name: Merge manifests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
needs: docker-build
|
||||
|
||||
env:
|
||||
BASE_TAGS: ${{ needs.docker-build.outputs.base-tags }}
|
||||
|
||||
environment:
|
||||
name: release
|
||||
deployment: false
|
||||
permissions:
|
||||
packages: write # Needed to upload packages and artifacts
|
||||
attestations: write # Needed to generate an artifact attestation for a build
|
||||
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ["debian","alpine"]
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*-${{ matrix.base_image }}
|
||||
@@ -314,14 +272,14 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
|
||||
- name: Add registry for DockerHub
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||
run: |
|
||||
@@ -329,15 +287,15 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
|
||||
- name: Add registry for ghcr.io
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
if: ${{ vars.GHCR_REPO != '' }}
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
run: |
|
||||
@@ -345,72 +303,85 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_TOKEN }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
|
||||
- name: Add registry for Quay.io
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
if: ${{ vars.QUAY_REPO != '' }}
|
||||
env:
|
||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Determine Base Tags
|
||||
- name: Determine Base Tags
|
||||
env:
|
||||
BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}"
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}"
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
- name: Create manifest list, push it and extract digest SHA
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
BASE_IMAGE: "${{ matrix.base_image }}"
|
||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||
run: |
|
||||
set +e
|
||||
IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}"
|
||||
IFS=',' read -ra TAGS <<< "${BASE_TAGS}"
|
||||
|
||||
TAG_ARGS=()
|
||||
for img in "${IMAGES[@]}"; do
|
||||
echo "Creating manifest for $img:${BASE_TAGS}-${BASE_IMAGE}"
|
||||
|
||||
OUTPUT=$(docker buildx imagetools create \
|
||||
-t "$img:${BASE_TAGS}-${BASE_IMAGE}" \
|
||||
$(printf "$img:${BASE_TAGS}-${BASE_IMAGE}@sha256:%s " *) 2>&1)
|
||||
STATUS=$?
|
||||
|
||||
if [ $STATUS -ne 0 ]; then
|
||||
echo "Manifest creation failed for $img"
|
||||
echo "$OUTPUT"
|
||||
exit $STATUS
|
||||
fi
|
||||
|
||||
echo "Manifest created for $img"
|
||||
echo "$OUTPUT"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("-t" "${img}:${tag}")
|
||||
done
|
||||
done
|
||||
set -e
|
||||
|
||||
echo "Creating manifest"
|
||||
if ! OUTPUT=$(docker buildx imagetools create \
|
||||
"${TAG_ARGS[@]}" \
|
||||
$(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then
|
||||
echo "Manifest creation failed"
|
||||
echo "${OUTPUT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Manifest created successfully"
|
||||
echo "${OUTPUT}"
|
||||
|
||||
# Extract digest SHA for subsequent steps
|
||||
GET_DIGEST_SHA="$(echo "$OUTPUT" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||
GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Attest container images
|
||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.GHCR_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ vars.QUAY_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Cleanup
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Trivy
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -29,12 +33,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||
@@ -46,6 +50,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
name: Code Spell Checking
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [ push, pull_request ]
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
typos:
|
||||
@@ -12,11 +16,11 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
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@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
|
||||
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
name: Security Analysis with zizmor
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,22 +11,20 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions: {}
|
||||
|
||||
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
with:
|
||||
# intentionally not scanning the entire repository,
|
||||
# since it contains integration tests.
|
||||
|
||||
+54
-53
@@ -1,58 +1,59 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=no"]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
- repo: local
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: mixed-line-ending
|
||||
args: [ "--fix=no" ]
|
||||
- id: end-of-file-fixer
|
||||
exclude: "(.*js$|.*css$)"
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-key
|
||||
- id: check-symlinks
|
||||
- id: forbid-submodules
|
||||
|
||||
# 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: 8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: ["--", "--check"]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
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$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
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$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
# 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: 2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
|
||||
hooks:
|
||||
- id: typos
|
||||
- id: typos
|
||||
always_run: true
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: fmt
|
||||
description: Format files with cargo fmt.
|
||||
entry: cargo fmt
|
||||
language: system
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
args: [ "--", "--check" ]
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: Test the package for errors.
|
||||
entry: cargo test
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust sources
|
||||
entry: cargo clippy
|
||||
language: system
|
||||
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
|
||||
@@ -23,4 +23,6 @@ extend-ignore-re = [
|
||||
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||
"AuthRequestResponseRecieved",
|
||||
# Ignore Punycode/IDN tests
|
||||
"xn--.+"
|
||||
]
|
||||
|
||||
Generated
+1386
-1390
File diff suppressed because it is too large
Load Diff
+162
-95
@@ -1,6 +1,6 @@
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
rust-version = "1.89.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95.0"
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
publish = false
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -24,20 +23,31 @@ publish.workspace = true
|
||||
[features]
|
||||
default = [
|
||||
# "sqlite",
|
||||
# "sqlite_system",
|
||||
# "mysql",
|
||||
# "postgresql",
|
||||
]
|
||||
# Empty to keep compatibility, prefer to set USE_SYSLOG=true
|
||||
enable_syslog = []
|
||||
# Please enable at least one of these DB backends.
|
||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"]
|
||||
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: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"]
|
||||
@@ -48,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"
|
||||
|
||||
@@ -55,154 +66,211 @@ syslog = "7.0.0"
|
||||
macros = { path = "./macros" }
|
||||
|
||||
# Logging
|
||||
log = "0.4.28"
|
||||
log = "0.4.33"
|
||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||
tracing = { version = "0.1.43", 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.9"
|
||||
num-derive = "0.5.1"
|
||||
bigdecimal = "0.4.10"
|
||||
|
||||
# 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.0" # MessagePack library
|
||||
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.31"
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
tokio-util = { version = "0.7.17", features = ["compat"]}
|
||||
futures = "0.3.33"
|
||||
tokio = { version = "1.53.1", 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.145"
|
||||
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.3", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.3.1"
|
||||
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.3.2"
|
||||
|
||||
derive_more = { version = "2.0.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"
|
||||
|
||||
# Bundled/Static SQLite
|
||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
|
||||
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||
libsqlite3-sys = { version = "0.37.0", optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = "0.9.2"
|
||||
rand = "0.10.2"
|
||||
ring = "0.17.14"
|
||||
rustls = { version = "0.23.43", features = ["ring", "std"], default-features = false }
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
uuid = { version = "1.24.0", features = ["v4"] }
|
||||
|
||||
# Date and time libraries
|
||||
chrono = { version = "0.4.42", 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.44"
|
||||
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.9.0"
|
||||
data-encoding = "2.11.1"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = { version = "10.2.0", features = ["use_pem", "rust_crypto"], default-features = false }
|
||||
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||
|
||||
# TOTP library
|
||||
totp-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
|
||||
# danger-credential-internals is needed to support U2F to Webauthn migration
|
||||
webauthn-rs = { version = "0.5.3", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
webauthn-rs-proto = "0.5.3"
|
||||
webauthn-rs-core = "0.5.3"
|
||||
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webauthn-rs-core = "0.5.5"
|
||||
|
||||
# Handling of URL's for WebAuthn and favicons
|
||||
url = "2.5.7"
|
||||
url = "2.5.8"
|
||||
|
||||
# Email libraries
|
||||
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
||||
lettre = { version = "0.11.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.3.2", features = ["dir_source"] }
|
||||
handlebars = { version = "6.4.3", features = ["dir_source"] }
|
||||
|
||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||
reqwest = { version = "0.12.24", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
||||
hickory-resolver = "0.25.2"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = [
|
||||
# Misc
|
||||
"charset",
|
||||
"cookies",
|
||||
"http2",
|
||||
"json",
|
||||
"form",
|
||||
"rustls-no-provider",
|
||||
"stream",
|
||||
# Compression
|
||||
"brotli",
|
||||
"deflate",
|
||||
"gzip",
|
||||
"zstd",
|
||||
# Proxy
|
||||
"socks",
|
||||
"system-proxy",
|
||||
] }
|
||||
hickory-resolver = "0.26.1"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.8.1"
|
||||
regex = { version = "1.12.2", 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.0"
|
||||
svg-hush = "0.9.5"
|
||||
bytes = "1.12.1"
|
||||
svg-hush = "0.9.6"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "0.56.0", features = ["async"] }
|
||||
cached = { version = "2.0.2", features = ["async"] }
|
||||
|
||||
# Used for custom short lived cookie jar during favicon extraction
|
||||
cookie = "0.18.1"
|
||||
cookie_store = "0.22.0"
|
||||
cookie_store = "0.22.1"
|
||||
|
||||
# Used by U2F, JWT and PostgreSQL
|
||||
openssl = "0.10.75"
|
||||
openssl = "0.10.81"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
|
||||
# Macro ident concatenation
|
||||
pastey = "0.2.0"
|
||||
governor = "0.10.2"
|
||||
pastey = "0.2.3"
|
||||
governor = "0.10.4"
|
||||
|
||||
# CIDR parsing for the trusted proxies of the client IP header
|
||||
ipnet = "2.12.1"
|
||||
|
||||
# OIDC for SSO
|
||||
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
|
||||
mini-moka = "0.10.3"
|
||||
openidconnect = { version = "4.0.1", default-features = false }
|
||||
moka = { version = "0.12.15", features = ["future"] }
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.27"
|
||||
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.48", features = ["secure"], default-features = false, optional = true }
|
||||
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
|
||||
|
||||
which = "8.0.0"
|
||||
which = "8.0.5"
|
||||
|
||||
# Argon2 library with support for the PHC format
|
||||
argon2 = "0.5.3"
|
||||
|
||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||
rpassword = "7.4.0"
|
||||
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.58.1", default-features = false, features = ["services-fs"] }
|
||||
|
||||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
anyhow = { version = "1.0.100", optional = true }
|
||||
aws-config = { version = "1.8.11", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-credential-types = { version = "1.2.10", optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.9.2", optional = true }
|
||||
http = { version = "1.4.0", optional = true }
|
||||
reqsign = { version = "0.16.5", optional = true }
|
||||
aws-config = { version = "1.10.1", 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.14.0", optional = true }
|
||||
http = { version = "1.5.0", optional = true }
|
||||
reqsign-aws-v4 = { version = "3.1.0", optional = true }
|
||||
reqsign-core = { version = "3.2.1", optional = true }
|
||||
|
||||
# Strip debuginfo from the release builds
|
||||
# The debug symbols are to provide better panic traces
|
||||
@@ -262,75 +330,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"
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -59,8 +59,9 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
|
||||
## Usage
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API).
|
||||
> That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS).
|
||||
> The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). <br>
|
||||
> That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). <br>
|
||||
> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples).
|
||||
|
||||
The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server).
|
||||
See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
use std::{env, io::Error, process::Command};
|
||||
|
||||
fn main() {
|
||||
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros
|
||||
#[cfg(feature = "sqlite")]
|
||||
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
||||
#[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one.
|
||||
println!("cargo:rustc-cfg=sqlite");
|
||||
#[cfg(feature = "mysql")]
|
||||
println!("cargo:rustc-cfg=mysql");
|
||||
#[cfg(feature = "postgresql")]
|
||||
println!("cargo:rustc-cfg=postgresql");
|
||||
#[cfg(feature = "s3")]
|
||||
println!("cargo:rustc-cfg=s3");
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
|
||||
#[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))]
|
||||
compile_error!(
|
||||
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
|
||||
);
|
||||
|
||||
#[cfg(feature = "s3")]
|
||||
println!("cargo:rustc-cfg=s3");
|
||||
|
||||
// Use check-cfg to let cargo know which cfg's we define,
|
||||
// and avoid warnings when they are used in the code.
|
||||
println!("cargo::rustc-check-cfg=cfg(sqlite)");
|
||||
@@ -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
-1
@@ -2,4 +2,4 @@
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
||||
file = "src/db/schema.rs"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
vault_version: "v2025.10.1"
|
||||
vault_image_digest: "sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa"
|
||||
# Cross Compile Docker Helper Scripts v1.8.0
|
||||
vault_version: "v2026.7.0"
|
||||
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
|
||||
# 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:add602d55daca18914838a78221f6bbe4284114b452c86a48f96d59aeb00f5c6"
|
||||
rust_version: 1.91.1 # Rust version to be used
|
||||
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||
rust_version: 1.97.1 # Rust version to be used
|
||||
debian_version: trixie # Debian release name to be used
|
||||
alpine_version: "3.22" # 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:v2025.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa
|
||||
# [docker.io/vaultwarden/web-vault:v2025.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c 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.91.1 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.91.1 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.91.1 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.91.1 AS build_armv6
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
|
||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 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.22
|
||||
# hadolint ignore=DL3065
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
||||
|
||||
ENV ROCKET_PROFILE="release" \
|
||||
ROCKET_ADDRESS=0.0.0.0 \
|
||||
|
||||
+35
-62
@@ -19,24 +19,25 @@
|
||||
# - 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:v2025.10.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.10.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
|
||||
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa
|
||||
# [docker.io/vaultwarden/web-vault:v2025.10.1]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
|
||||
# [docker.io/vaultwarden/web-vault:v2026.7.0]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:50662dccf4908ac2128cd44981c52fcb4e3e8dd56f21823c8d5e91267ff741fa AS vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||
## And these bash scripts do not have any significant difference if at all
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:add602d55daca18914838a78221f6bbe4284114b452c86a48f96d59aeb00f5c6 AS xx
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.91.1-slim-trixie AS build
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
|
||||
# hadolint ignore=DL3067
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -51,16 +52,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TERM=xterm-256color \
|
||||
CARGO_HOME="/root/.cargo" \
|
||||
USER="root"
|
||||
|
||||
# Force the install of an older MariaDB library to prevent a Diesel panic
|
||||
# See https://github.com/dani-garcia/vaultwarden/issues/6416
|
||||
RUN echo "deb http://snapshot.debian.org/archive/debian/20250707T084701Z/ trixie main" > /etc/apt/sources.list.d/snapshot.list && \
|
||||
echo "Acquire::Check-Valid-Until false;" > etc/apt/apt.conf.d/AllowSnapshot && \
|
||||
echo 'Package: libmariadb libmariadb3 libmariadb-dev mariadb*' > /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin: origin "snapshot.debian.org"' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin-Priority: 1001' >> /etc/apt/preferences.d/mariadb-snapshot
|
||||
|
||||
# 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
|
||||
@@ -68,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
|
||||
|
||||
@@ -92,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
|
||||
@@ -130,8 +96,15 @@ 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
export XX_RUSTFLAGS; \
|
||||
fi && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
find . -not -path "./target*" -delete
|
||||
|
||||
# Copies the complete project
|
||||
@@ -141,13 +114,20 @@ 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
export XX_RUSTFLAGS; \
|
||||
fi && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||
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 ; \
|
||||
@@ -170,6 +150,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" \
|
||||
@@ -179,14 +160,6 @@ ENV ROCKET_PROFILE="release" \
|
||||
|
||||
# Create data folder and Install needed libraries
|
||||
RUN mkdir /data && \
|
||||
# Force the install of an older MariaDB library to prevent a Diesel panic
|
||||
# See https://github.com/dani-garcia/vaultwarden/issues/6416
|
||||
echo "deb http://snapshot.debian.org/archive/debian/20250707T084701Z/ trixie main" > /etc/apt/sources.list.d/snapshot.list && \
|
||||
echo "Acquire::Check-Valid-Until false;" > etc/apt/apt.conf.d/AllowSnapshot && \
|
||||
echo 'Package: libmariadb libmariadb3 libmariadb-dev mariadb*' > /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin: origin "snapshot.debian.org"' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin-Priority: 1001' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
# Continue with normal install
|
||||
apt-get update && apt-get install -y \
|
||||
--no-install-recommends \
|
||||
ca-certificates \
|
||||
|
||||
+35
-59
@@ -19,14 +19,24 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version }}
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version }}
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version }}]
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||
#
|
||||
{% 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="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
|
||||
export XX_RUSTFLAGS; \
|
||||
fi && \
|
||||
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
|
||||
{%- endmacro %}
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||
|
||||
{% if base == "debian" %}
|
||||
@@ -47,6 +57,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,19 +77,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" %}
|
||||
|
||||
# Force the install of an older MariaDB library to prevent a Diesel panic
|
||||
# See https://github.com/dani-garcia/vaultwarden/issues/6416
|
||||
RUN echo "deb http://snapshot.debian.org/archive/debian/20250707T084701Z/ trixie main" > /etc/apt/sources.list.d/snapshot.list && \
|
||||
echo "Acquire::Check-Valid-Until false;" > etc/apt/apt.conf.d/AllowSnapshot && \
|
||||
echo 'Package: libmariadb libmariadb3 libmariadb-dev mariadb*' > /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin: origin "snapshot.debian.org"' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin-Priority: 1001' >> /etc/apt/preferences.d/mariadb-snapshot
|
||||
|
||||
# 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
|
||||
@@ -86,19 +88,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 %}
|
||||
@@ -111,38 +110,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
|
||||
@@ -162,8 +137,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
|
||||
@@ -173,13 +152,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 ; \
|
||||
@@ -202,6 +185,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" \
|
||||
@@ -216,14 +200,6 @@ ENV ROCKET_PROFILE="release" \
|
||||
# Create data folder and Install needed libraries
|
||||
RUN mkdir /data && \
|
||||
{% if base == "debian" %}
|
||||
# Force the install of an older MariaDB library to prevent a Diesel panic
|
||||
# See https://github.com/dani-garcia/vaultwarden/issues/6416
|
||||
echo "deb http://snapshot.debian.org/archive/debian/20250707T084701Z/ trixie main" > /etc/apt/sources.list.d/snapshot.list && \
|
||||
echo "Acquire::Check-Valid-Until false;" > etc/apt/apt.conf.d/AllowSnapshot && \
|
||||
echo 'Package: libmariadb libmariadb3 libmariadb-dev mariadb*' > /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin: origin "snapshot.debian.org"' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
echo 'Pin-Priority: 1001' >> /etc/apt/preferences.d/mariadb-snapshot && \
|
||||
# Continue with normal install
|
||||
apt-get update && apt-get install -y \
|
||||
--no-install-recommends \
|
||||
ca-certificates \
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.42"
|
||||
syn = "2.0.110"
|
||||
quote = "1.0.47"
|
||||
syn = "3.0.3"
|
||||
|
||||
[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]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,10 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
cipher_uuid CHAR(36) NOT NULL,
|
||||
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_uuid, cipher_uuid),
|
||||
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
|
||||
archived_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_uuid, cipher_uuid)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP TABLE IF EXISTS sso_auth;
|
||||
|
||||
CREATE TABLE sso_nonce (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
verifier TEXT,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP TABLE IF EXISTS sso_nonce;
|
||||
|
||||
CREATE TABLE sso_auth (
|
||||
state TEXT NOT NULL PRIMARY KEY,
|
||||
client_challenge TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_response TEXT,
|
||||
auth_response TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
@@ -0,0 +1,8 @@
|
||||
DROP TABLE IF EXISTS archives;
|
||||
|
||||
CREATE TABLE archives (
|
||||
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
|
||||
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
|
||||
archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_uuid, cipher_uuid)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth DROP COLUMN binding_hash;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
+14
-16
@@ -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 usse its own [test.env](/test/scenarios/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
|
||||
```
|
||||
@@ -65,7 +65,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
||||
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||
|
||||
```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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
|
||||
|
||||
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 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 page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
|
||||
|
||||
@@ -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,35 @@ 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.user1, { mailBuffer: mail1Buffer });
|
||||
|
||||
let newPassword = "TotoNewPassword";
|
||||
|
||||
await orgs.members(test, page, 'Test');
|
||||
await test.step(`Recover ${users.user2.email}`, async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
|
||||
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
|
||||
await page.getByRole('menuitem', { name: 'Recover account' }).click();
|
||||
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
|
||||
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Account recovery success');
|
||||
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
|
||||
});
|
||||
|
||||
let user2 = {
|
||||
email: users.user2.email,
|
||||
name: users.user2.name,
|
||||
password: newPassword,
|
||||
};
|
||||
await logUser(test, page, user2, {
|
||||
mailBuffer: mail2Buffer,
|
||||
notNewDevice: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
import { createAccount, logUser } from './setups/user';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
let totp;
|
||||
|
||||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
await utils.startVault(browser, testInfo, {});
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await createAccount(test, page, users.user1);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async ({}) => {
|
||||
utils.stopVault();
|
||||
});
|
||||
|
||||
test('Password', async ({ context, page }, testInfo: TestInfo) => {
|
||||
const label = 'Test Password';
|
||||
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Create password entry', async () => {
|
||||
await page.getByRole('button', { name: 'New item' }).click();
|
||||
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
|
||||
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Item added');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Check', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||
await page.getByTestId('copy-username').click();
|
||||
await utils.checkNotification(page, 'Username copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
|
||||
await page.getByTestId('copy-password').click();
|
||||
await utils.checkNotification(page, 'Password copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
await test.step('Delete', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await utils.checkNotification(page, 'Item sent to bin');
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Deleted', async () => {
|
||||
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
|
||||
const label = 'Test SSH key';
|
||||
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
const privateKey = await test.step('Create key entry', async () => {
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'SSH key' }).click();
|
||||
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await utils.checkNotification(page, 'Item added');
|
||||
|
||||
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||
await utils.checkNotification(page, 'Private key copied');
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Check', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy private key' }).click();
|
||||
await utils.checkNotification(page, 'Private key copied');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
await test.step('Delete', async () => {
|
||||
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
await utils.checkNotification(page, 'Item sent to bin');
|
||||
});
|
||||
|
||||
// Log again
|
||||
await logUser(test, page, users.user1);
|
||||
|
||||
await test.step('Deleted', async () => {
|
||||
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
import * as utils from "../global-utils";
|
||||
import { createAccount } from './setups/user';
|
||||
|
||||
let users = utils.loadEnv();
|
||||
|
||||
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||
await utils.startVault(browser, testInfo, {});
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async ({}) => {
|
||||
utils.stopVault();
|
||||
});
|
||||
|
||||
test('Send', async ({ browser, page }) => {
|
||||
await createAccount(test, page, users.user1);
|
||||
|
||||
const send_url = await test.step('Create', async () => {
|
||||
await page.getByRole('link', { name: 'Send' }).click();
|
||||
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'New Send', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
|
||||
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
});
|
||||
|
||||
const context2 = await browser.newContext();
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
await test.step('View', async () => {
|
||||
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
|
||||
});
|
||||
|
||||
const pwd_url = await test.step('Create with password', async () => {
|
||||
await page.getByRole('link', { name: 'Send' }).click();
|
||||
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'New' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Text' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
|
||||
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
|
||||
await page.getByRole('combobox', { name: 'Who can view' }).click();
|
||||
await page.getByText('Anyone with a password set by you').click();
|
||||
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
|
||||
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
});
|
||||
|
||||
await test.step('View with password', async () => {
|
||||
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
|
||||
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
|
||||
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
|
||||
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: '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());
|
||||
@@ -33,8 +34,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: '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 +50,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: '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 +82,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
|
||||
await page.getByRole('link', { name: 'Security' }).click();
|
||||
await page.getByRole('link', { name: '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"));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
|
||||
import { type MailBuffer } from 'maildev';
|
||||
|
||||
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 +18,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 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
||||
});
|
||||
}
|
||||
|
||||
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) {
|
||||
export async function logUser(
|
||||
test,
|
||||
page: Page,
|
||||
user: { email: string, password: string },
|
||||
options: {
|
||||
mailBuffer ?: MailBuffer,
|
||||
mail2fa?: boolean,
|
||||
notNewDevice?: boolean,
|
||||
} = {}
|
||||
) {
|
||||
await test.step(`Log user ${user.email}`, async () => {
|
||||
await utils.cleanLanding(page);
|
||||
|
||||
@@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// 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 ){
|
||||
await test.step('2FA check', async () => {
|
||||
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
|
||||
let code = await retrieveEmailCode(test, page, options.mailBuffer);
|
||||
await page.getByLabel(/Verification code/).fill(code);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
});
|
||||
}
|
||||
|
||||
// We are now in the default vault page
|
||||
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.91.1"
|
||||
channel = "1.97.1"
|
||||
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"
|
||||
|
||||
+176
-95
@@ -2,39 +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_display_size, get_web_vault_version,
|
||||
is_running_in_container, 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> {
|
||||
@@ -92,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);
|
||||
|
||||
@@ -199,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);
|
||||
@@ -223,10 +217,16 @@ 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") => {
|
||||
@@ -306,17 +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 = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
let invitation = Invitation::new(&user.email);
|
||||
@@ -324,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))
|
||||
@@ -381,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);
|
||||
}
|
||||
@@ -464,15 +468,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
|
||||
match unregister_push_device(&device.push_uuid).await {
|
||||
match unregister_push_device(device.push_uuid.as_ref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
|
||||
user.save(&conn).await
|
||||
}
|
||||
@@ -480,14 +484,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
|
||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
user.enabled = false;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -517,8 +522,12 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||
FAKE_SSO_IDENTIFIER.into()
|
||||
} else {
|
||||
FAKE_ADMIN_UUID.into()
|
||||
};
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
|
||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -544,9 +553,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 {
|
||||
@@ -633,12 +643,11 @@ async fn has_http_access() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use cached::macros::cached;
|
||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||
/// 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
|
||||
use std::time::Duration; // Needed for cached
|
||||
#[cached(time = 600, sync_writes = "default")]
|
||||
#[cached(ttl = 600, sync_writes = "default")]
|
||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||
if has_http_access {
|
||||
@@ -647,48 +656,96 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from("Unable to fetch NTP time.")
|
||||
}
|
||||
|
||||
fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
||||
use semver::Version;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
let active_semver = Version::parse(active).unwrap_or_else(|e| {
|
||||
warn!("Unable to parse active web-vault version '{active}': {e}");
|
||||
Version::parse("2025.1.1").unwrap()
|
||||
});
|
||||
let latest_semver = Version::parse(latest).unwrap_or_else(|e| {
|
||||
warn!("Unable to parse latest web-vault version '{latest}': {e}");
|
||||
Version::parse("2025.1.1").unwrap()
|
||||
});
|
||||
|
||||
match active_semver.cmp(&latest_semver) {
|
||||
Ordering::Less => -1,
|
||||
Ordering::Equal => 0,
|
||||
Ordering::Greater => 1,
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
@@ -705,35 +762,31 @@ 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_release, latest_commit, latest_web_build) = get_release_info(has_http_access).await;
|
||||
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||
let active_web_release = get_active_web_release();
|
||||
let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release);
|
||||
|
||||
let ip_header_name = &ip_header.0.unwrap_or_default();
|
||||
|
||||
// Get current running versions
|
||||
let web_vault_version = get_web_vault_version();
|
||||
|
||||
// Check if the running version is newer than the latest stable released version
|
||||
let web_vault_pre_release = if let Ok(web_ver_match) = semver::VersionReq::parse(&format!(">{latest_web_build}")) {
|
||||
web_ver_match.matches(
|
||||
&semver::Version::parse(&web_vault_version).unwrap_or_else(|_| semver::Version::parse("2025.1.1").unwrap()),
|
||||
)
|
||||
} else {
|
||||
error!("Unable to parse latest_web_build: '{latest_web_build}'");
|
||||
false
|
||||
};
|
||||
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||
&CONFIG.experimental_client_feature_flags(),
|
||||
&FeatureFlagFilter::InvalidOnly,
|
||||
)
|
||||
.into_keys()
|
||||
.collect();
|
||||
|
||||
let diagnostics_json = json!({
|
||||
"dns_resolved": dns_resolved,
|
||||
"current_release": VERSION,
|
||||
"latest_release": latest_release,
|
||||
"latest_commit": latest_commit,
|
||||
"latest_release": latest_vw_release,
|
||||
"latest_commit": latest_vw_commit,
|
||||
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
||||
"web_vault_version": web_vault_version,
|
||||
"latest_web_build": latest_web_build,
|
||||
"web_vault_pre_release": web_vault_pre_release,
|
||||
"active_web_release": active_web_release,
|
||||
"latest_web_release": latest_web_release,
|
||||
"web_vault_compare": web_vault_compare,
|
||||
"running_within_container": running_within_container,
|
||||
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
||||
"has_http_access": has_http_access,
|
||||
@@ -747,6 +800,8 @@ 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,
|
||||
"tz_env": env::var("TZ").unwrap_or_default(),
|
||||
@@ -808,33 +863,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"));
|
||||
}
|
||||
}
|
||||
@@ -844,3 +896,32 @@ impl<'r> FromRequest<'r> for AdminToken {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_web_vault_compare() {
|
||||
// web_vault_compare(active, latest)
|
||||
// Test normal versions
|
||||
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
||||
|
||||
// Test patched/+build.n versions
|
||||
// Newer latest version
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
||||
// Equal versions
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
||||
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
||||
// Newer active version
|
||||
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
||||
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
||||
}
|
||||
}
|
||||
|
||||
+255
-147
@@ -1,39 +1,41 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::db::DbPool;
|
||||
use chrono::Utc;
|
||||
use rocket::serde::json::Json;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
},
|
||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
|
||||
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
|
||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
mail,
|
||||
util::{format_date, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
use rocket::{
|
||||
http::Status,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
serde::json::Json,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
master_password_policy, register_push_device, unregister_push_device,
|
||||
},
|
||||
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
|
||||
crypto,
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
|
||||
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId,
|
||||
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
||||
},
|
||||
},
|
||||
mail,
|
||||
util::{NumberOrString, deser_opt_nonempty_str, format_date},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ciphers::{CipherData, update_cipher_from_data},
|
||||
sends::{SendData, update_send_from_data},
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
routes![
|
||||
register,
|
||||
profile,
|
||||
put_profile,
|
||||
post_profile,
|
||||
@@ -55,9 +57,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
delete_account,
|
||||
revision_date,
|
||||
password_hint,
|
||||
prelogin,
|
||||
post_prelogin,
|
||||
verify_password,
|
||||
api_key,
|
||||
post_api_key,
|
||||
rotate_api_key,
|
||||
get_known_device,
|
||||
get_all_devices,
|
||||
@@ -66,6 +68,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
put_device_token,
|
||||
put_clear_device_token,
|
||||
post_clear_device_token,
|
||||
get_tasks,
|
||||
post_auth_request,
|
||||
get_auth_request,
|
||||
put_auth_request,
|
||||
@@ -94,19 +97,15 @@ pub struct RegisterData {
|
||||
email: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
kdf: KDFData,
|
||||
compat: RegisterDataCompat,
|
||||
|
||||
#[serde(alias = "userSymmetricKey")]
|
||||
key: String,
|
||||
#[serde(alias = "userAsymmetricKeys")]
|
||||
keys: Option<KeysData>,
|
||||
|
||||
master_password_hash: String,
|
||||
master_password_hint: Option<String>,
|
||||
|
||||
name: Option<String>,
|
||||
|
||||
#[allow(dead_code)]
|
||||
organization_user_id: Option<MembershipId>,
|
||||
|
||||
// Used only from the register/finish endpoint
|
||||
@@ -117,6 +116,102 @@ pub struct RegisterData {
|
||||
org_invite_token: Option<String>,
|
||||
}
|
||||
|
||||
impl RegisterData {
|
||||
fn hash(&self) -> String {
|
||||
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
|
||||
}
|
||||
|
||||
fn kdf(&self) -> &KDFData {
|
||||
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
|
||||
}
|
||||
|
||||
fn key(&self) -> String {
|
||||
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
|
||||
}
|
||||
|
||||
// When comparing with salt, email need to be normalized:
|
||||
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
|
||||
fn unprocessable(&self) -> bool {
|
||||
let mut unprocessable = false;
|
||||
*self.compat.fold(
|
||||
|_| &false,
|
||||
|rdcu| {
|
||||
let email = self.email.trim().to_lowercase();
|
||||
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|
||||
|| rdcu.master_password_authentication.salt != email
|
||||
|| rdcu.master_password_unlock.salt != email;
|
||||
&unprocessable
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegisterDataOld {
|
||||
#[serde(flatten)]
|
||||
kdf: KDFData,
|
||||
|
||||
#[serde(alias = "userSymmetricKey")]
|
||||
key: String,
|
||||
|
||||
#[serde(alias = "masterPasswordHash")]
|
||||
master_password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterDataCur {
|
||||
master_password_authentication: MasterPasswordAuthentication,
|
||||
master_password_unlock: MasterPasswordUnlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RegisterDataCompat {
|
||||
RegisterDataOld(RegisterDataOld),
|
||||
RegisterDataCur(RegisterDataCur),
|
||||
}
|
||||
|
||||
impl RegisterDataCompat {
|
||||
fn fold<'a, T>(
|
||||
&'a self,
|
||||
fct: impl FnOnce(&'a RegisterDataOld) -> &'a T,
|
||||
fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T,
|
||||
) -> &'a T {
|
||||
match self {
|
||||
RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc),
|
||||
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeysData {
|
||||
encrypted_private_key: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MasterPasswordAuthentication {
|
||||
kdf: KDFData,
|
||||
salt: String,
|
||||
|
||||
#[serde(alias = "masterPasswordAuthenticationHash")]
|
||||
hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MasterPasswordUnlock {
|
||||
kdf: KDFData,
|
||||
salt: String,
|
||||
|
||||
#[serde(alias = "masterKeyWrappedUserKey")]
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetPasswordData {
|
||||
@@ -130,25 +225,18 @@ pub struct SetPasswordData {
|
||||
org_identifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeysData {
|
||||
encrypted_private_key: String,
|
||||
public_key: String,
|
||||
}
|
||||
|
||||
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
|
||||
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
|
||||
match password_hint {
|
||||
None => None,
|
||||
Some(h) => match h.trim() {
|
||||
"" => None,
|
||||
ht => Some(ht.to_string()),
|
||||
ht => Some(ht.to_owned()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_password_hint_setting(password_hint: &Option<String>) -> EmptyResult {
|
||||
fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult {
|
||||
if password_hint.is_some() && !CONFIG.password_hints_allowed() {
|
||||
err!("Password hints have been disabled by the administrator. Remove the hint and try again.");
|
||||
}
|
||||
@@ -167,12 +255,7 @@ async fn is_email_2fa_required(member_id: Option<MembershipId>, conn: &DbConn) -
|
||||
false
|
||||
}
|
||||
|
||||
#[post("/accounts/register", data = "<data>")]
|
||||
async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, false, conn).await
|
||||
}
|
||||
|
||||
pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||
pub async fn register(data: Json<RegisterData>, email_verification: bool, conn: DbConn) -> JsonResult {
|
||||
let mut data: RegisterData = data.into_inner();
|
||||
let email = data.email.to_lowercase();
|
||||
|
||||
@@ -180,6 +263,10 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
|
||||
let mut pending_emergency_access = None;
|
||||
|
||||
if data.unprocessable() {
|
||||
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
|
||||
}
|
||||
|
||||
// First, validate the provided verification tokens
|
||||
if email_verification {
|
||||
match (
|
||||
@@ -243,16 +330,16 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
|
||||
// Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden)
|
||||
// This also prevents issues with very long usernames causing to large JWT's. See #2419
|
||||
if let Some(ref name) = data.name {
|
||||
if name.len() > 50 {
|
||||
err!("The field Name must be a string with a maximum length of 50.");
|
||||
}
|
||||
if let Some(ref name) = data.name
|
||||
&& name.len() > 50
|
||||
{
|
||||
err!("The field Name must be a string with a maximum length of 50.");
|
||||
}
|
||||
|
||||
// Check against the password hint setting here so if it fails, the user
|
||||
// can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
|
||||
let mut user = match User::find_by_mail(&email, &conn).await {
|
||||
Some(user) => {
|
||||
@@ -260,8 +347,8 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
|
||||
if let Some(token) = data.org_invite_token {
|
||||
let claims = decode_invite(&token)?;
|
||||
if let Some(token) = data.org_invite_token.as_ref() {
|
||||
let claims = decode_invite(token)?;
|
||||
if claims.email == email {
|
||||
// Verify the email address when signing up via a valid invite token
|
||||
email_verified = true;
|
||||
@@ -299,9 +386,9 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
|
||||
// Make sure we don't leave a lingering invitation.
|
||||
Invitation::take(&email, &conn).await;
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
set_kdf_data(&mut user, data.kdf())?;
|
||||
|
||||
user.set_password(&data.master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
// Add extra fields if present
|
||||
@@ -359,8 +446,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
|
||||
// Check against the password hint setting here so if it fails,
|
||||
// the user can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
let password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(password_hint.as_ref())?;
|
||||
|
||||
set_kdf_data(&mut user, &data.kdf)?;
|
||||
|
||||
@@ -369,7 +456,9 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
Some(data.key),
|
||||
false,
|
||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
user.password_hint = password_hint;
|
||||
|
||||
if let Some(keys) = data.keys {
|
||||
@@ -377,20 +466,19 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
user.public_key = Some(keys.public_key);
|
||||
}
|
||||
|
||||
if let Some(identifier) = data.org_identifier {
|
||||
if identifier != crate::sso::FAKE_IDENTIFIER {
|
||||
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await {
|
||||
None => err!("Failed to retrieve the associated organization"),
|
||||
Some(org) => org,
|
||||
};
|
||||
if let Some(identifier) = data.org_identifier
|
||||
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER
|
||||
&& identifier != crate::api::admin::FAKE_ADMIN_UUID
|
||||
{
|
||||
let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
|
||||
err!("Failed to retrieve the associated organization")
|
||||
};
|
||||
|
||||
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
|
||||
None => err!("Failed to retrieve the invitation"),
|
||||
Some(org) => org,
|
||||
};
|
||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
|
||||
err!("Failed to retrieve the invitation")
|
||||
};
|
||||
|
||||
accept_org_invite(&user, membership, None, &conn).await?;
|
||||
}
|
||||
accept_org_invite(&user, membership, None, &conn).await?;
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -405,8 +493,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
|
||||
user.save(&conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"Object": "set-password",
|
||||
"CaptchaBypassToken": "",
|
||||
"object": "set-password",
|
||||
"captchaBypassToken": "",
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -457,10 +545,10 @@ async fn put_avatar(data: Json<AvatarData>, headers: Headers, conn: DbConn) -> J
|
||||
// It looks like it only supports the 6 hex color format.
|
||||
// If you try to add the short value it will not show that color.
|
||||
// Check and force 7 chars, including the #.
|
||||
if let Some(color) = &data.avatar_color {
|
||||
if color.len() != 7 {
|
||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||
}
|
||||
if let Some(color) = &data.avatar_color
|
||||
&& color.len() != 7
|
||||
{
|
||||
err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters")
|
||||
}
|
||||
|
||||
let mut user = headers.user;
|
||||
@@ -521,8 +609,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
user.password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&user.password_hint)?;
|
||||
user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
|
||||
enforce_password_hint_setting(user.password_hint.as_ref())?;
|
||||
|
||||
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||
.await;
|
||||
@@ -537,14 +625,16 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
|
||||
String::from("get_public_keys"),
|
||||
String::from("get_api_webauthn"),
|
||||
]),
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -584,7 +674,6 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthenticationData {
|
||||
@@ -593,7 +682,6 @@ struct AuthenticationData {
|
||||
master_password_authentication_hash: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UnlockData {
|
||||
@@ -602,12 +690,9 @@ struct UnlockData {
|
||||
master_key_wrapped_user_key: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChangeKdfData {
|
||||
new_master_password_hash: String,
|
||||
key: String,
|
||||
authentication_data: AuthenticationData,
|
||||
unlock_data: UnlockData,
|
||||
master_password_hash: String,
|
||||
@@ -638,10 +723,12 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
|
||||
Some(data.unlock_data.master_key_wrapped_user_key),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -652,6 +739,7 @@ struct UpdateFolderData {
|
||||
// There is a bug in 2024.3.x which adds a `null` item.
|
||||
// To bypass this we allow a Option here, but skip it during the updates
|
||||
// See: https://github.com/bitwarden/clients/issues/8453
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
id: Option<FolderId>,
|
||||
name: String,
|
||||
}
|
||||
@@ -670,9 +758,6 @@ struct UpdateResetPasswordData {
|
||||
reset_password_key: String,
|
||||
}
|
||||
|
||||
use super::ciphers::CipherData;
|
||||
use super::sends::{update_send_from_data, SendData};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyData {
|
||||
@@ -842,7 +927,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
saved_folder.name = folder_data.name;
|
||||
saved_folder.save(&conn).await?
|
||||
saved_folder.save(&conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +940,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||
saved_emergency_access.save(&conn).await?
|
||||
saved_emergency_access.save(&conn).await?;
|
||||
}
|
||||
|
||||
// Update reset password data
|
||||
@@ -867,7 +952,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
};
|
||||
|
||||
membership.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||
membership.save(&conn).await?
|
||||
membership.save(&conn).await?;
|
||||
}
|
||||
|
||||
// Update send data
|
||||
@@ -880,8 +965,6 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
}
|
||||
|
||||
// Update cipher data
|
||||
use super::ciphers::update_cipher_from_data;
|
||||
|
||||
for cipher_data in data.account_data.ciphers {
|
||||
if cipher_data.organization_id.is_none() {
|
||||
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
||||
@@ -892,7 +975,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,14 +988,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
// Prevent logging out the client where the user requested this endpoint from.
|
||||
// If you do logout the user it will causes issues at the client side.
|
||||
// Adding the device uuid will prevent this.
|
||||
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await;
|
||||
nt.send_logout(&user, Some(&headers.device), &conn).await;
|
||||
|
||||
save_result
|
||||
}
|
||||
@@ -924,12 +1009,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
||||
|
||||
data.validate(&user, true, &conn).await?;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.reset_security_stamp(&conn).await?;
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
nt.send_logout(&user, None, &conn).await;
|
||||
|
||||
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
|
||||
save_result
|
||||
}
|
||||
|
||||
@@ -1019,24 +1105,22 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
||||
err!("Email already in use");
|
||||
}
|
||||
|
||||
match user.email_new {
|
||||
Some(ref val) => {
|
||||
if val != &data.new_email {
|
||||
err!("Email change mismatch");
|
||||
}
|
||||
if let Some(ref val) = user.email_new {
|
||||
if val != &data.new_email {
|
||||
err!("Email change mismatch");
|
||||
}
|
||||
None => err!("No email change pending"),
|
||||
} else {
|
||||
err!("No email change pending")
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
// Only check the token if we sent out an email...
|
||||
match user.email_new_token {
|
||||
Some(ref val) => {
|
||||
if *val != data.token.into_string() {
|
||||
err!("Token mismatch");
|
||||
}
|
||||
if let Some(ref val) = user.email_new_token {
|
||||
if *val != data.token.into_string() {
|
||||
err!("Token mismatch");
|
||||
}
|
||||
None => err!("No email change pending"),
|
||||
} else {
|
||||
err!("No email change pending")
|
||||
}
|
||||
user.verified_at = Some(Utc::now().naive_utc());
|
||||
} else {
|
||||
@@ -1047,7 +1131,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
|
||||
user.email_new = None;
|
||||
user.email_new_token = None;
|
||||
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None);
|
||||
user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
|
||||
let save_result = user.save(&conn).await;
|
||||
|
||||
@@ -1109,14 +1193,16 @@ struct DeleteRecoverData {
|
||||
}
|
||||
|
||||
#[post("/accounts/delete-recover", data = "<data>")]
|
||||
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult {
|
||||
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
let data: DeleteRecoverData = data.into_inner();
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await {
|
||||
if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await {
|
||||
error!("Error sending delete account email: {e:#?}");
|
||||
}
|
||||
if let Some(user) = User::find_by_mail(&data.email, &conn).await
|
||||
&& let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await
|
||||
{
|
||||
error!("Error sending delete account email: {e:#?}");
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1168,6 +1254,7 @@ async fn delete_account(data: Json<PasswordOrOtpData>, headers: Headers, conn: D
|
||||
user.delete(&conn).await
|
||||
}
|
||||
|
||||
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||
#[get("/accounts/revision-date")]
|
||||
fn revision_date(headers: Headers) -> JsonResult {
|
||||
let revision_date = headers.user.updated_at.and_utc().timestamp_millis();
|
||||
@@ -1181,13 +1268,15 @@ struct PasswordHintData {
|
||||
}
|
||||
|
||||
#[post("/accounts/password-hint", data = "<data>")]
|
||||
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
||||
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||
err!("This server is not configured to provide password hints.");
|
||||
}
|
||||
|
||||
const NO_HINT: &str = "Sorry, you have no password hint...";
|
||||
|
||||
let data: PasswordHintData = data.into_inner();
|
||||
let email = &data.email;
|
||||
|
||||
@@ -1198,10 +1287,9 @@ async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResul
|
||||
// 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, Rng, SeedableRng};
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let delta: i32 = 100;
|
||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
||||
use rand::{RngExt, rngs::SmallRng};
|
||||
let mut rng: SmallRng = rand::make_rng();
|
||||
let sleep_ms: u64 = rng.random_range(900..=1100);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1229,11 +1317,11 @@ pub struct PreloginData {
|
||||
}
|
||||
|
||||
#[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>, conn: DbConn) -> Json<Value> {
|
||||
prelogin(data, conn).await
|
||||
}
|
||||
|
||||
pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
let data: PreloginData = data.into_inner();
|
||||
|
||||
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
|
||||
@@ -1246,6 +1334,13 @@ pub async fn _prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
"kdfIterations": kdf_iter,
|
||||
"kdfMemory": kdf_mem,
|
||||
"kdfParallelism": kdf_para,
|
||||
"kdfSettings": {
|
||||
"iterations": kdf_iter,
|
||||
"kdfType": kdf_type,
|
||||
"memory": kdf_mem,
|
||||
"parallelism": kdf_para
|
||||
},
|
||||
"salt": null,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1260,7 +1355,7 @@ struct SecretVerificationRequest {
|
||||
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
|
||||
if user.password_iterations < CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(pwd_hash, None, false, None);
|
||||
user.set_password(pwd_hash, None, false, None, conn).await?;
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
@@ -1283,9 +1378,7 @@ async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers
|
||||
Ok(Json(master_password_policy(&user, &conn).await))
|
||||
}
|
||||
|
||||
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
use crate::util::format_date;
|
||||
|
||||
async fn update_api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
@@ -1304,13 +1397,13 @@ async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers,
|
||||
}
|
||||
|
||||
#[post("/accounts/api-key", data = "<data>")]
|
||||
async fn api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
_api_key(data, false, headers, conn).await
|
||||
async fn post_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
update_api_key(data, false, headers, conn).await
|
||||
}
|
||||
|
||||
#[post("/accounts/rotate-api-key", data = "<data>")]
|
||||
async fn rotate_api_key(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
_api_key(data, true, headers, conn).await
|
||||
update_api_key(data, true, headers, conn).await
|
||||
}
|
||||
|
||||
#[get("/devices/knowndevice")]
|
||||
@@ -1334,6 +1427,11 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
|
||||
// Bitwarden seems to send padded Base64 strings since 2026.2.1
|
||||
// Since these values are not streamed and Headers are always split by newlines
|
||||
// we can safely ignore padding here and remove any '=' appended.
|
||||
let email_b64 = email_b64.trim_end_matches('=');
|
||||
|
||||
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
|
||||
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
||||
};
|
||||
@@ -1348,7 +1446,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
};
|
||||
|
||||
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
|
||||
uuid.to_string().into()
|
||||
uuid.to_owned().into()
|
||||
} else {
|
||||
return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
|
||||
};
|
||||
@@ -1363,7 +1461,7 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
#[get("/devices")]
|
||||
async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await;
|
||||
let devices = devices.iter().map(|device| device.to_json()).collect::<Vec<Value>>();
|
||||
let devices = devices.iter().map(DeviceWithAuthRequest::to_json).collect::<Vec<Value>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": devices,
|
||||
@@ -1409,7 +1507,7 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
||||
}
|
||||
|
||||
device.push_token = Some(token);
|
||||
if let Err(e) = device.save(&conn).await {
|
||||
if let Err(e) = device.save(true, &conn).await {
|
||||
err!(format!("An error occurred while trying to save the device push token: {e}"));
|
||||
}
|
||||
|
||||
@@ -1419,7 +1517,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
|
||||
}
|
||||
|
||||
#[put("/devices/identifier/<device_id>/clear-token")]
|
||||
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
||||
async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
// This only clears push token
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
|
||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
|
||||
@@ -1433,7 +1533,7 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
||||
|
||||
if let Some(device) = Device::find_by_uuid(&device_id, &conn).await {
|
||||
Device::clear_push_token_by_uuid(&device_id, &conn).await?;
|
||||
unregister_push_device(&device.push_uuid).await?;
|
||||
unregister_push_device(device.push_uuid.as_ref()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1441,8 +1541,16 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
|
||||
|
||||
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
|
||||
#[post("/devices/identifier/<device_id>/clear-token")]
|
||||
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
|
||||
put_clear_device_token(device_id, conn).await
|
||||
async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
|
||||
put_clear_device_token(device_id, ip, conn).await
|
||||
}
|
||||
|
||||
#[get("/tasks")]
|
||||
fn get_tasks(_client_headers: ClientHeaders) -> JsonResult {
|
||||
Ok(Json(json!({
|
||||
"data": [],
|
||||
"object": "list"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1695,6 +1803,6 @@ pub async fn purge_auth_requests(pool: DbPool) {
|
||||
if let Ok(conn) = pool.get().await {
|
||||
AuthRequest::purge_expired_auth_requests(&conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging trashed ciphers")
|
||||
error!("Failed to get DB connection while purging auth requests");
|
||||
}
|
||||
}
|
||||
|
||||
+412
-195
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,23 @@
|
||||
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},
|
||||
},
|
||||
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 +55,7 @@ async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||
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 +89,14 @@ async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
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 +139,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 +209,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;
|
||||
@@ -342,12 +347,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.
|
||||
@@ -653,7 +657,7 @@ async fn password_emergency_access(
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
|
||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||
grantor_user.save(&conn).await?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
@@ -766,7 +770,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 +829,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");
|
||||
}
|
||||
}
|
||||
|
||||
+62
-62
@@ -1,18 +1,18 @@
|
||||
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::{
|
||||
CONFIG,
|
||||
api::{EmptyResult, JsonResult},
|
||||
auth::{AdminHeaders, Headers},
|
||||
db::{
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
DbConn, DbPool,
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
},
|
||||
util::parse_date,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
/// ###############################################################################################################
|
||||
@@ -38,9 +38,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
@@ -51,8 +49,10 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -64,27 +64,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
|
||||
|
||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||
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 = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
} else {
|
||||
parse_date(&data.end)
|
||||
};
|
||||
|
||||
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,9 +101,7 @@ 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 events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
@@ -120,8 +112,10 @@ async fn get_user_events(
|
||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
.map(Event::to_json)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -134,7 +128,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 {
|
||||
@@ -176,7 +171,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
let event_date = parse_date(&event.date);
|
||||
match event.r#type {
|
||||
1000..=1099 => {
|
||||
_log_user_event(
|
||||
log_user_event_impl(
|
||||
event.r#type,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
@@ -187,8 +182,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
.await;
|
||||
}
|
||||
1600..=1699 => {
|
||||
if let Some(org_id) = &event.organization_id {
|
||||
_log_event(
|
||||
// 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,
|
||||
@@ -202,22 +200,24 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,10 +229,10 @@ pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32,
|
||||
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, 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,
|
||||
@@ -240,7 +240,7 @@ async fn _log_user_event(
|
||||
ip: &IpAddr,
|
||||
conn: &DbConn,
|
||||
) {
|
||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
||||
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||
|
||||
// Upstream saves the event also without any org_id.
|
||||
@@ -278,11 +278,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, 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 +298,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 +338,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,9 +5,10 @@ use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{Folder, FolderId},
|
||||
DbConn,
|
||||
models::{Folder, FolderId},
|
||||
},
|
||||
util::deser_opt_nonempty_str,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<rocket::Route> {
|
||||
@@ -28,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderData {
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||
pub id: Option<FolderId>,
|
||||
}
|
||||
|
||||
|
||||
+69
-52
@@ -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,24 +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,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GlobalDomain {
|
||||
@@ -72,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();
|
||||
|
||||
@@ -109,34 +110,45 @@ 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?;
|
||||
|
||||
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await;
|
||||
nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[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>")]
|
||||
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
||||
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
||||
if let Some(api_key) = CONFIG.hibp_api_key() {
|
||||
let url = format!(
|
||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||
);
|
||||
@@ -197,19 +209,17 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
||||
|
||||
#[get("/config")]
|
||||
fn config() -> Json<Value> {
|
||||
let domain = crate::CONFIG.domain();
|
||||
let domain = CONFIG.domain();
|
||||
// Official available feature flags can be found here:
|
||||
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
|
||||
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
|
||||
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||
let mut feature_states =
|
||||
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
|
||||
feature_states.insert("duo-redirect".to_string(), true);
|
||||
feature_states.insert("email-verification".to_string(), true);
|
||||
feature_states.insert("unauth-ui-refresh".to_string(), true);
|
||||
feature_states.insert("enable-pm-flight-recorder".to_string(), true);
|
||||
feature_states.insert("mobile-error-reporting".to_string(), true);
|
||||
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
|
||||
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||
let mut feature_states = parse_experimental_client_feature_flags(
|
||||
&CONFIG.experimental_client_feature_flags(),
|
||||
&FeatureFlagFilter::ValidOnly,
|
||||
);
|
||||
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 +227,18 @@ fn config() -> Json<Value> {
|
||||
// We should make sure that we keep this updated when we support the new server features
|
||||
// Version history:
|
||||
// - Individual cipher key encryption: 2024.2.0
|
||||
"version": "2025.6.0",
|
||||
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||
"version": "2026.6.0",
|
||||
"gitHash": option_env!("GIT_REV"),
|
||||
"server": {
|
||||
"name": "Vaultwarden",
|
||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||
},
|
||||
"settings": {
|
||||
"disableUserRegistration": crate::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",
|
||||
}))
|
||||
}
|
||||
@@ -277,10 +295,9 @@ async fn accept_org_invite(
|
||||
|
||||
member.save(conn).await?;
|
||||
|
||||
if crate::CONFIG.mail_enabled() {
|
||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
||||
Some(org) => org,
|
||||
None => err!("Organization not found."),
|
||||
if CONFIG.mail_enabled() {
|
||||
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)
|
||||
|
||||
+461
-645
File diff suppressed because it is too large
Load Diff
+73
-69
@@ -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> {
|
||||
@@ -83,26 +84,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.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 +117,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
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,40 +131,36 @@ 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()),
|
||||
);
|
||||
group.save(&conn).await?;
|
||||
group.uuid
|
||||
}
|
||||
let group_uuid = if let Some(group) =
|
||||
Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await
|
||||
{
|
||||
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, &conn).await?;
|
||||
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||
|
||||
for ext_id in &group_data.member_external_ids {
|
||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
||||
@@ -174,18 +178,17 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
|
||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||
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;
|
||||
}
|
||||
if let Some(ref user_external_id) = member.external_id
|
||||
&& !sync_members.contains(user_external_id)
|
||||
{
|
||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||
// Removing owner, check that there is at least one other confirmed owner
|
||||
if 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?;
|
||||
}
|
||||
member.delete(&conn).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,12 +205,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
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 +234,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")
|
||||
};
|
||||
|
||||
+94
-56
@@ -10,15 +10,15 @@ use rocket::{
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::{ClientIp, Headers, Host},
|
||||
auth::{ClientIp, 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";
|
||||
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
post_send,
|
||||
post_send_file,
|
||||
post_access,
|
||||
post_access_legacy,
|
||||
post_access_file,
|
||||
post_access_file_legacy,
|
||||
put_send,
|
||||
delete_send,
|
||||
put_remove_password,
|
||||
@@ -63,7 +65,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ pub struct SendData {
|
||||
deletion_date: DateTime<Utc>,
|
||||
disabled: bool,
|
||||
hide_email: Option<bool>,
|
||||
emails: Option<String>,
|
||||
|
||||
// Data field
|
||||
name: String,
|
||||
@@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
||||
);
|
||||
}
|
||||
|
||||
if data.emails.is_some() {
|
||||
err!("Sends with email verification is not supported");
|
||||
}
|
||||
|
||||
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
||||
send.user_uuid = Some(user_id);
|
||||
send.notes = data.notes;
|
||||
@@ -168,7 +175,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 +186,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,9 +318,10 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
||||
|
||||
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||
|
||||
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 +378,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,
|
||||
@@ -439,41 +448,45 @@ async fn post_send_file_v2_data(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/sends/access")]
|
||||
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
process_access(send, conn, nt).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendAccessData {
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
// Legacy since web-2026.6.0
|
||||
#[post("/sends/access/<access_id>", data = "<data>")]
|
||||
async fn post_access(
|
||||
async fn post_access_legacy(
|
||||
access_id: &str,
|
||||
data: Json<SendAccessData>,
|
||||
conn: DbConn,
|
||||
ip: ClientIp,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
if send.access_count >= max_access_count {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
if let Some(max_access_count) = send.max_access_count
|
||||
&& send.access_count >= max_access_count
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||
}
|
||||
|
||||
if let Some(expiration) = send.expiration_date {
|
||||
if Utc::now().naive_utc() >= expiration {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
}
|
||||
|
||||
if Utc::now().naive_utc() >= send.deletion_date {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if send.disabled {
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
@@ -487,11 +500,17 @@ async fn post_access(
|
||||
|
||||
// Files are incremented during the download
|
||||
if send.atype == SendType::Text as i32 {
|
||||
send.access_count += 1;
|
||||
if !send.register_access(&conn).await? {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
} else {
|
||||
send.save(&conn).await?;
|
||||
}
|
||||
|
||||
send.save(&conn).await?;
|
||||
process_access(send, conn, nt).await
|
||||
}
|
||||
|
||||
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
@@ -504,36 +523,47 @@ 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(
|
||||
file_id: SendFileId,
|
||||
headers: SendHeaders,
|
||||
host: Host,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
process_access_file(send, file_id, host, conn, nt).await
|
||||
}
|
||||
|
||||
// Legacy since web-2026.6.0
|
||||
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
||||
async fn post_access_file_legacy(
|
||||
send_id: SendId,
|
||||
file_id: SendFileId,
|
||||
data: Json<SendAccessData>,
|
||||
host: Host,
|
||||
conn: DbConn,
|
||||
ip: ClientIp,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||
|
||||
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
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 let Some(max_access_count) = send.max_access_count
|
||||
&& send.access_count >= max_access_count
|
||||
{
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
if send.disabled {
|
||||
if !send.is_accessible() {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
@@ -545,10 +575,14 @@ async fn post_access_file(
|
||||
}
|
||||
}
|
||||
|
||||
send.access_count += 1;
|
||||
if !send.register_access(&conn).await? {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
}
|
||||
|
||||
send.save(&conn).await?;
|
||||
process_access_file(send, file_id, host, conn, nt).await
|
||||
}
|
||||
|
||||
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
@@ -561,29 +595,29 @@ 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_secs(5 * 60)).await?.uri().to_string())
|
||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[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,6 +633,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
|
||||
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
||||
};
|
||||
|
||||
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()))
|
||||
|
||||
@@ -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},
|
||||
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||
auth::{ClientIp, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
util::NumberOrString,
|
||||
};
|
||||
@@ -70,9 +69,10 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
||||
.await?;
|
||||
|
||||
// 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,7 +82,7 @@ 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;
|
||||
|
||||
@@ -119,7 +119,7 @@ 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")
|
||||
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
|
||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||
{
|
||||
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 +145,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.
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
use chrono::Utc;
|
||||
use data_encoding::BASE64;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
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::generate_recover_code,
|
||||
},
|
||||
auth::Headers,
|
||||
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> {
|
||||
@@ -82,8 +81,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,
|
||||
}
|
||||
}
|
||||
@@ -182,7 +180,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn)
|
||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||
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;
|
||||
|
||||
@@ -201,14 +199,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
||||
}
|
||||
|
||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||
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 +214,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()?;
|
||||
@@ -356,9 +354,10 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
||||
err!("Invalid ikey")
|
||||
}
|
||||
|
||||
let expire: i64 = match expire.parse() {
|
||||
Ok(e) => e,
|
||||
Err(_) => err!("Invalid expire time"),
|
||||
let expire: i64 = if let Ok(e) = expire.parse() {
|
||||
e
|
||||
} else {
|
||||
err!("Invalid expire time")
|
||||
};
|
||||
|
||||
if time >= expire {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -438,16 +439,13 @@ pub async fn validate_duo_login(
|
||||
|
||||
// Get the context by the state reported by the client. If we don't have one,
|
||||
// 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,20 +1,20 @@
|
||||
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::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
auth::{ClientHeaders, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
mail, CONFIG,
|
||||
mail,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -24,37 +24,85 @@ pub fn routes() -> Vec<Route> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SendEmailLoginData {
|
||||
#[serde(alias = "DeviceIdentifier")]
|
||||
device_identifier: Option<DeviceId>,
|
||||
#[serde(alias = "Email")]
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
master_password_hash: String,
|
||||
master_password_hash: Option<String>,
|
||||
auth_request_id: Option<AuthRequestId>,
|
||||
auth_request_access_code: Option<String>,
|
||||
}
|
||||
|
||||
/// User is trying to login and wants to use email 2FA.
|
||||
/// Does not require Bearer token
|
||||
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
||||
async fn send_email_login(data: Json<SendEmailLoginData>, conn: DbConn) -> EmptyResult {
|
||||
async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult {
|
||||
let data: SendEmailLoginData = data.into_inner();
|
||||
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
if !CONFIG._enable_email_2fa() {
|
||||
err!("Email 2FA is disabled")
|
||||
}
|
||||
|
||||
// Check password
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&client_headers.ip.ip)?;
|
||||
|
||||
send_token(&user.uuid, &conn).await?;
|
||||
// Get the user
|
||||
let email = match &data.email {
|
||||
Some(email) if !email.is_empty() => Some(email),
|
||||
_ => None,
|
||||
};
|
||||
let master_password_hash = match &data.master_password_hash {
|
||||
Some(password_hash) if !password_hash.is_empty() => Some(password_hash),
|
||||
_ => None,
|
||||
};
|
||||
let auth_request_id = match &data.auth_request_id {
|
||||
Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
let user = if let Some(email) = email {
|
||||
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
if let Some(master_password_hash) = master_password_hash {
|
||||
// Check password
|
||||
if !user.check_valid_password(master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
} else if let Some(auth_request_id) = auth_request_id {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||
err!("AuthRequest doesn't exist", "User not found")
|
||||
};
|
||||
let Some(code) = &data.auth_request_access_code else {
|
||||
err!("no auth request access code")
|
||||
};
|
||||
|
||||
if auth_request.device_type != client_headers.device_type
|
||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||
|| !auth_request.check_access_code(code)
|
||||
{
|
||||
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||
}
|
||||
} else {
|
||||
err!("No password hash has been submitted.")
|
||||
}
|
||||
|
||||
user
|
||||
} else {
|
||||
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(device_identifier, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
user
|
||||
};
|
||||
|
||||
send_token(&user.uuid, &conn).await
|
||||
}
|
||||
|
||||
/// Generate the token, save the data for later verification and send email to user
|
||||
@@ -184,7 +232,7 @@ 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;
|
||||
|
||||
@@ -236,9 +284,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 {
|
||||
@@ -294,9 +342,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,18 +363,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,26 +1,27 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use data_encoding::BASE32;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{Route, serde::json::Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
api::{
|
||||
core::{log_event, log_user_event},
|
||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||
core::{log_event, log_user_event},
|
||||
},
|
||||
auth::{ClientHeaders, Headers},
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
DbConn, DbPool,
|
||||
models::{
|
||||
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||
TwoFactorIncomplete, User, UserId,
|
||||
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub mod authenticator;
|
||||
@@ -31,11 +32,46 @@ pub mod protected_actions;
|
||||
pub mod webauthn;
|
||||
pub mod yubikey;
|
||||
|
||||
fn has_global_duo_credentials() -> bool {
|
||||
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||
}
|
||||
|
||||
pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||
#[derive(Deserialize)]
|
||||
struct DuoProviderData {
|
||||
host: String,
|
||||
ik: String,
|
||||
sk: String,
|
||||
}
|
||||
|
||||
match provider_type {
|
||||
TwoFactorType::Authenticator | TwoFactorType::RecoveryCode => true,
|
||||
TwoFactorType::Email => CONFIG._enable_email_2fa(),
|
||||
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
|
||||
provider_data
|
||||
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
|
||||
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|
||||
|| has_global_duo_credentials()
|
||||
}
|
||||
TwoFactorType::YubiKey => {
|
||||
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
|
||||
}
|
||||
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
|
||||
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
|
||||
TwoFactorType::U2f
|
||||
| TwoFactorType::U2fRegisterChallenge
|
||||
| TwoFactorType::U2fLoginChallenge
|
||||
| TwoFactorType::EmailVerificationChallenge
|
||||
| TwoFactorType::WebauthnRegisterChallenge
|
||||
| TwoFactorType::WebauthnLoginChallenge
|
||||
| TwoFactorType::ProtectedActions => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut routes = routes![
|
||||
get_twofactor,
|
||||
get_recover,
|
||||
recover,
|
||||
disable_twofactor,
|
||||
disable_twofactor_put,
|
||||
get_device_verification_settings,
|
||||
@@ -54,7 +90,13 @@ pub fn routes() -> Vec<Route> {
|
||||
#[get("/two-factor")]
|
||||
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
||||
let twofactors_json: Vec<Value> = twofactors
|
||||
.iter()
|
||||
.filter_map(|tf| {
|
||||
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(json!({
|
||||
"data": twofactors_json,
|
||||
@@ -76,55 +118,7 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RecoverTwoFactor {
|
||||
master_password_hash: String,
|
||||
email: String,
|
||||
recovery_code: String,
|
||||
}
|
||||
|
||||
#[post("/two-factor/recover", data = "<data>")]
|
||||
async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, conn: DbConn) -> JsonResult {
|
||||
let data: RecoverTwoFactor = data.into_inner();
|
||||
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let Some(mut user) = User::find_by_mail(&data.email, &conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
// Check password
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
|
||||
// Check if recovery code is correct
|
||||
if !user.check_valid_recovery_code(&data.recovery_code) {
|
||||
err!("Recovery code is incorrect. Try again.")
|
||||
}
|
||||
|
||||
// Remove all twofactors from the user
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?;
|
||||
|
||||
log_user_event(
|
||||
EventType::UserRecovered2fa as i32,
|
||||
&user.uuid,
|
||||
client_headers.device_type,
|
||||
&client_headers.ip.ip,
|
||||
&conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Remove the recovery code, not needed without twofactors
|
||||
user.totp_recover = None;
|
||||
user.save(&conn).await?;
|
||||
Ok(Json(Value::Object(serde_json::Map::new())))
|
||||
}
|
||||
|
||||
async fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
if user.totp_recover.is_none() {
|
||||
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||
user.totp_recover = Some(totp_recover);
|
||||
@@ -184,9 +178,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() {
|
||||
@@ -221,7 +213,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() {
|
||||
@@ -255,12 +247,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();
|
||||
@@ -282,7 +271,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,7 +64,9 @@ 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;
|
||||
@@ -102,7 +106,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;
|
||||
@@ -133,7 +139,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")
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
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::{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::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
crypto::ct_eq,
|
||||
db::{
|
||||
DbConn,
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
},
|
||||
error::Error,
|
||||
util::NumberOrString,
|
||||
};
|
||||
|
||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
let domain = CONFIG.domain();
|
||||
let domain_origin = CONFIG.domain_origin();
|
||||
@@ -38,7 +39,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
|
||||
.expect("Creating WebauthnBuilder failed")
|
||||
.rp_name(&domain)
|
||||
.timeout(Duration::from_millis(60000));
|
||||
.timeout(Duration::from_mins(1));
|
||||
|
||||
webauthn.build().expect("Building Webauthn failed")
|
||||
});
|
||||
@@ -108,8 +109,8 @@ impl WebauthnRegistration {
|
||||
|
||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
if !CONFIG.domain_set() {
|
||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
||||
if !CONFIG.is_webauthn_2fa_supported() {
|
||||
err!("Configured `DOMAIN` is not compatible with Webauthn")
|
||||
}
|
||||
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
@@ -144,12 +145,12 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
||||
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||
&user.email,
|
||||
&user.name,
|
||||
user.display_name(),
|
||||
Some(registrations),
|
||||
)?;
|
||||
|
||||
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;
|
||||
@@ -265,13 +266,12 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
|
||||
// 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, type_, &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
|
||||
@@ -291,7 +291,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.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;
|
||||
|
||||
@@ -342,9 +342,10 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
|
||||
// If entry is migrated from u2f, delete the u2f entry as well
|
||||
if 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"),
|
||||
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
||||
d
|
||||
} else {
|
||||
err!("Error parsing U2F data")
|
||||
};
|
||||
|
||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||
@@ -388,10 +389,10 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
|
||||
// Modify to discourage user verification
|
||||
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;
|
||||
@@ -416,18 +417,17 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes
|
||||
|
||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||
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, type_, 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)?;
|
||||
@@ -438,7 +438,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
// We need to check for and update the backup_eligible flag when needed.
|
||||
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
|
||||
// Because of this we check the flag at runtime and update the registrations and state when needed
|
||||
check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?;
|
||||
let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
|
||||
|
||||
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
|
||||
|
||||
@@ -446,7 +446,8 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
|
||||
// If the cred id matches and the credential is updated, Some(true) is returned
|
||||
// In those cases, update the record, else leave it alone
|
||||
if reg.credential.update_credential(&authentication_result) == Some(true) {
|
||||
let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true);
|
||||
if credential_updated || backup_flags_updated {
|
||||
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.save(conn)
|
||||
.await?;
|
||||
@@ -463,13 +464,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
)
|
||||
}
|
||||
|
||||
async fn check_and_update_backup_eligible(
|
||||
user_id: &UserId,
|
||||
fn check_and_update_backup_eligible(
|
||||
rsp: &PublicKeyCredential,
|
||||
registrations: &mut Vec<WebauthnRegistration>,
|
||||
state: &mut PasskeyAuthentication,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
) -> Result<bool, Error> {
|
||||
// The feature flags from the response
|
||||
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
|
||||
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
|
||||
@@ -486,16 +485,7 @@ async fn check_and_update_backup_eligible(
|
||||
let rsp_id = rsp.raw_id.as_slice();
|
||||
for reg in &mut *registrations {
|
||||
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
|
||||
// Try to update the key, and if needed also update the database, before the actual state check is done
|
||||
if reg.set_backup_eligible(backup_eligible, backup_state) {
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::Webauthn,
|
||||
serde_json::to_string(®istrations)?,
|
||||
)
|
||||
.save(conn)
|
||||
.await?;
|
||||
|
||||
// We also need to adjust the current state which holds the challenge used to start the authentication verification
|
||||
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
|
||||
let mut raw_state = serde_json::to_value(&state)?;
|
||||
@@ -517,11 +507,12 @@ async fn check_and_update_backup_eligible(
|
||||
}
|
||||
|
||||
*state = serde_json::from_value(raw_state)?;
|
||||
return Ok(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
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::generate_recover_code},
|
||||
},
|
||||
auth::Headers,
|
||||
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,]
|
||||
}
|
||||
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnableYubikeyData {
|
||||
@@ -45,8 +75,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 +93,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>")]
|
||||
@@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
||||
let yubikeys = parse_yubikeys(&data);
|
||||
|
||||
if yubikeys.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"object": "twoFactorU2f",
|
||||
})));
|
||||
// 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.");
|
||||
}
|
||||
|
||||
// Ensure they are valid OTPs
|
||||
@@ -162,7 +193,7 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
|
||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||
yubikey_data.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;
|
||||
|
||||
|
||||
+115
-119
@@ -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, should_block_address, 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
|
||||
@@ -81,19 +82,19 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
|
||||
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
||||
// It is used to prevent sending a specific header which breaks icon downloads.
|
||||
// If this function needs to be renamed, also adjust the code in `util.rs`
|
||||
#[get("/<domain>/icon.png")]
|
||||
fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
|
||||
if !is_valid_domain(domain) {
|
||||
warn!("Invalid domain: {domain}");
|
||||
#[get("/<host>/icon.png")]
|
||||
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
};
|
||||
|
||||
if should_block_host(&host).is_err() {
|
||||
warn!("Blocked address: {host}");
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
}
|
||||
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {domain}");
|
||||
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
|
||||
}
|
||||
|
||||
let url = CONFIG._icon_service_url().replace("{}", domain);
|
||||
let url = CONFIG._icon_service_url().replace("{}", &host.to_string());
|
||||
let redir = match CONFIG.icon_redirect_code() {
|
||||
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
||||
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
||||
@@ -107,12 +108,21 @@ fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
|
||||
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
|
||||
}
|
||||
|
||||
#[get("/<domain>/icon.png")]
|
||||
async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
#[get("/<host>/icon.png")]
|
||||
async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
|
||||
|
||||
if !is_valid_domain(domain) {
|
||||
warn!("Invalid domain: {domain}");
|
||||
let Ok(host) = get_valid_host(host) else {
|
||||
warn!("Invalid host: {host}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if should_block_host(&host).is_err() {
|
||||
warn!("Blocked address: {host}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
@@ -120,16 +130,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
);
|
||||
}
|
||||
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {domain}");
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
match get_icon(domain).await {
|
||||
match get_icon(&host.to_string()).await {
|
||||
Some((icon, icon_type)) => {
|
||||
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
|
||||
}
|
||||
@@ -137,42 +138,6 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns if the domain provided is valid or not.
|
||||
///
|
||||
/// This does some manual checks and makes use of Url to do some basic checking.
|
||||
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
|
||||
fn is_valid_domain(domain: &str) -> bool {
|
||||
const ALLOWED_CHARS: &str = "-.";
|
||||
|
||||
// If parsing the domain fails using Url, it will not work with reqwest.
|
||||
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
|
||||
debug!("Domain parse error: '{domain}' - {parse_error:?}");
|
||||
return false;
|
||||
} else if domain.is_empty()
|
||||
|| domain.contains("..")
|
||||
|| domain.starts_with('.')
|
||||
|| domain.starts_with('-')
|
||||
|| domain.ends_with('-')
|
||||
{
|
||||
debug!(
|
||||
"Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'"
|
||||
);
|
||||
return false;
|
||||
} else if domain.len() > 255 {
|
||||
debug!("Domain validation error: '{domain}' exceeds 255 characters");
|
||||
return false;
|
||||
}
|
||||
|
||||
for c in domain.chars() {
|
||||
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
|
||||
debug!("Domain validation error: '{domain}' contains an invalid character '{c}'");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
let path = format!("{domain}.png");
|
||||
|
||||
@@ -183,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() {
|
||||
@@ -194,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
|
||||
@@ -219,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
|
||||
@@ -316,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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +332,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
tld = domain_parts.next_back().unwrap(),
|
||||
base = domain_parts.next_back().unwrap()
|
||||
);
|
||||
if is_valid_domain(&base_domain) {
|
||||
if get_valid_host(&base_domain).is_ok() {
|
||||
let sslbase = format!("https://{base_domain}");
|
||||
let httpbase = format!("http://{base_domain}");
|
||||
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
|
||||
@@ -378,7 +343,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
// When the domain is not an IP, and has less then 2 dots, try to add www. infront of it.
|
||||
} else if is_ip.is_err() && domain.matches('.').count() < 2 {
|
||||
let www_domain = format!("www.{domain}");
|
||||
if is_valid_domain(&www_domain) {
|
||||
if get_valid_host(&www_domain).is_ok() {
|
||||
let sslwww = format!("https://{www_domain}");
|
||||
let httpwww = format!("http://{www_domain}");
|
||||
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
|
||||
@@ -440,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()?)
|
||||
@@ -513,13 +494,11 @@ fn parse_sizes(sizes: &str) -> (u16, u16) {
|
||||
|
||||
if !sizes.is_empty() {
|
||||
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
||||
None => {}
|
||||
Some(dimensions) => {
|
||||
if dimensions.len() >= 3 {
|
||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||
}
|
||||
Some(dimensions) if dimensions.len() >= 3 => {
|
||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,11 +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;
|
||||
|
||||
for icon in icon_result.iconlist.iter().take(5) {
|
||||
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
|
||||
@@ -560,13 +538,25 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
||||
}
|
||||
}
|
||||
_ => debug!("Extracted icon from data:image uri is invalid"),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
let res = get_page_with_referer(&icon.href, &icon_result.referer).await?;
|
||||
debug!("Trying {}", icon.href);
|
||||
// Make sure all icons are checked before returning error
|
||||
let res = match get_page_with_referer(&icon.href, &icon_result.referer).await {
|
||||
Ok(r) => r,
|
||||
Err(e) if icons.peek().is_none() => return Err(e),
|
||||
Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon()
|
||||
Err(e) => {
|
||||
warn!("Unable to download icon: {e:?}");
|
||||
|
||||
// Continue to next icon
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net)
|
||||
|
||||
// Check if the icon type is allowed, else try an icon from the list.
|
||||
// Check if the icon type is allowed, else try another icon from the list.
|
||||
icon_type = get_icon_type(&buffer);
|
||||
if icon_type.is_none() {
|
||||
buffer.clear();
|
||||
@@ -612,22 +602,25 @@ 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
|
||||
}
|
||||
|
||||
// Some details can be found here:
|
||||
// - https://www.garykessler.net/library/file_sigs_GCK_latest.html
|
||||
// - https://en.wikipedia.org/wiki/List_of_file_signatures
|
||||
match bytes {
|
||||
[137, 80, 78, 71, ..] => Some("png"),
|
||||
[0, 0, 1, 0, ..] => Some("x-icon"),
|
||||
[82, 73, 70, 70, ..] => Some("webp"),
|
||||
[255, 216, 255, ..] => Some("jpeg"),
|
||||
[71, 73, 70, 56, ..] => Some("gif"),
|
||||
[66, 77, ..] => Some("bmp"),
|
||||
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
|
||||
[137, 80, 78, 71, 13, 10, 26, 10, ..] => Some("png"),
|
||||
[0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
[82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images
|
||||
[255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"),
|
||||
[71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"),
|
||||
[66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format
|
||||
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
|
||||
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
|
||||
_ => None,
|
||||
}
|
||||
@@ -755,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;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
@@ -797,8 +790,11 @@ impl Emitter for FaviconEmitter {
|
||||
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
||||
self.flush_current_attribute(true);
|
||||
self.last_start_tag.clear();
|
||||
if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing {
|
||||
self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name);
|
||||
match &self.current_token {
|
||||
Some(token) if !token.closing => {
|
||||
self.last_start_tag.extend(&*token.tag.name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
html5gum::naive_next_state(&self.last_start_tag)
|
||||
}
|
||||
@@ -825,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+415
-216
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user