mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-07-22 09:33:33 +00:00
Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a02e80a5a | |||
| 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 | |||
| 07569a06da | |||
| cb2f5741ac | |||
| c9d527d84f | |||
| 7c7f4f5d4f | |||
| aad1f19b45 | |||
| 35e1a306f3 | |||
| 7f7b412220 | |||
| bb41f64c0a | |||
| 319d982113 | |||
| 95a0c667e4 | |||
| b519832086 | |||
| 2ee40d6105 | |||
| 0182567a62 | |||
| f9751a0a1d | |||
| 9017ca265a | |||
| 8d30285160 | |||
| 3cd3d33d00 | |||
| 2ee5819b56 | |||
| 7c597e88f9 | |||
| a85b48512c | |||
| fe1a8f7738 | |||
| d43edb8f17 | |||
| 8043f7eca7 | |||
| e659a61581 | |||
| 2d54cc61df | |||
| 3f010a50af | |||
| e83faad8d2 | |||
| a79cd40ea9 | |||
| b1d84298cc | |||
| a2ad1dc7c3 | |||
| 7cc4dfabbf | |||
| 5a8736e116 | |||
| f76362ff89 | |||
| 6db5b7115d | |||
| 3510351f4d | |||
| 7161f612a1 | |||
| 5ee908517f | |||
| 55577fa4eb | |||
| 843c063649 | |||
| 550b670dba | |||
| de808c5ad9 | |||
| 1f73630136 | |||
| 77008a91e9 | |||
| 7f386d38ae | |||
| 8e7eeab293 | |||
| e35c6f8705 | |||
| ae7b725c0f | |||
| 2a5489a4b2 | |||
| 8fd0ee4211 | |||
| 4a5516e150 | |||
| 7fc94516ce | |||
| 5ea0779d6b |
+57
-20
@@ -50,10 +50,11 @@
|
|||||||
#########################
|
#########################
|
||||||
|
|
||||||
## Database URL
|
## Database URL
|
||||||
## When using SQLite, this is the path to the DB file, and it defaults to
|
## When using SQLite, this should use the sqlite:// scheme followed by the path
|
||||||
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this
|
## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
|
||||||
## must be set to a local sqlite3 file path.
|
## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
|
||||||
# DATABASE_URL=data/db.sqlite3
|
## but only if the database file already exists.
|
||||||
|
# DATABASE_URL=sqlite://data/db.sqlite3
|
||||||
## When using MySQL, specify an appropriate connection URI.
|
## When using MySQL, specify an appropriate connection URI.
|
||||||
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
|
||||||
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
# DATABASE_URL=mysql://user:password@host[:port]/database_name
|
||||||
@@ -80,8 +81,16 @@
|
|||||||
## Timeout when acquiring database connection
|
## Timeout when acquiring database connection
|
||||||
# DATABASE_TIMEOUT=30
|
# DATABASE_TIMEOUT=30
|
||||||
|
|
||||||
|
## Database idle timeout
|
||||||
|
## Timeout in seconds before idle connections to the database are closed.
|
||||||
|
# DATABASE_IDLE_TIMEOUT=600
|
||||||
|
|
||||||
|
## Database min connections
|
||||||
|
## Define the minimum size of the connection pool used for connecting to the database.
|
||||||
|
# DATABASE_MIN_CONNS=2
|
||||||
|
|
||||||
## Database max connections
|
## Database max connections
|
||||||
## Define the size of the connection pool used for connecting to the database.
|
## Define the maximum size of the connection pool used for connecting to the database.
|
||||||
# DATABASE_MAX_CONNS=10
|
# DATABASE_MAX_CONNS=10
|
||||||
|
|
||||||
## Database connection initialization
|
## Database connection initialization
|
||||||
@@ -175,9 +184,9 @@
|
|||||||
## Defaults to every minute. Set blank to disable this job.
|
## Defaults to every minute. Set blank to disable this job.
|
||||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||||
#
|
#
|
||||||
## Cron schedule of the job that cleans sso nonce from incomplete flow
|
## Cron schedule of the job that cleans sso auth from incomplete flow
|
||||||
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
||||||
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
|
# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *"
|
||||||
|
|
||||||
########################
|
########################
|
||||||
### General settings ###
|
### General settings ###
|
||||||
@@ -315,6 +324,14 @@
|
|||||||
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||||
# IP_HEADER=X-Real-IP
|
# IP_HEADER=X-Real-IP
|
||||||
|
|
||||||
|
## Which addresses the header above is accepted from, defaults to "local".
|
||||||
|
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
|
||||||
|
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
|
||||||
|
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
|
||||||
|
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
|
||||||
|
## proxy as IPs and CIDR ranges if it connects from a public address.
|
||||||
|
# IP_HEADER_TRUSTED_PROXIES=local
|
||||||
|
|
||||||
## Icon service
|
## Icon service
|
||||||
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||||
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||||
@@ -340,7 +357,7 @@
|
|||||||
## Default: 2592000 (30 days)
|
## Default: 2592000 (30 days)
|
||||||
# ICON_CACHE_TTL=2592000
|
# ICON_CACHE_TTL=2592000
|
||||||
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
|
||||||
## Default: 2592000 (3 days)
|
## Default: 259200 (3 days)
|
||||||
# ICON_CACHE_NEGTTL=259200
|
# ICON_CACHE_NEGTTL=259200
|
||||||
|
|
||||||
## Icon download timeout
|
## Icon download timeout
|
||||||
@@ -364,15 +381,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!
|
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension.
|
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
|
||||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
|
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
|
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
|
||||||
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0)
|
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
|
||||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
|
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
|
||||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0)
|
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0)
|
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
|
## - "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.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
## If sending the email fails the login attempt will fail!!
|
## If sending the email fails the login attempt will fail!!
|
||||||
@@ -444,6 +469,13 @@
|
|||||||
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
|
||||||
# LOGIN_RATELIMIT_MAX_BURST=10
|
# LOGIN_RATELIMIT_MAX_BURST=10
|
||||||
|
|
||||||
|
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
|
||||||
|
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
|
||||||
|
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
|
||||||
|
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
|
||||||
|
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
|
||||||
|
|
||||||
## BETA FEATURE: Groups
|
## BETA FEATURE: Groups
|
||||||
## Controls whether group support is enabled for organizations
|
## Controls whether group support is enabled for organizations
|
||||||
## This setting applies to organizations.
|
## This setting applies to organizations.
|
||||||
@@ -463,6 +495,11 @@
|
|||||||
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
## Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
|
||||||
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
|
# ENFORCE_SINGLE_ORG_WITH_RESET_PW_POLICY=false
|
||||||
|
|
||||||
|
## Prefer IPv6 (AAAA) resolving
|
||||||
|
## This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4
|
||||||
|
## This could be useful in IPv6 only environments.
|
||||||
|
# DNS_PREFER_IPV6=false
|
||||||
|
|
||||||
#####################################
|
#####################################
|
||||||
### SSO settings (OpenID Connect) ###
|
### SSO settings (OpenID Connect) ###
|
||||||
#####################################
|
#####################################
|
||||||
@@ -485,7 +522,7 @@
|
|||||||
# SSO_AUTHORITY=https://auth.example.com
|
# SSO_AUTHORITY=https://auth.example.com
|
||||||
|
|
||||||
## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit).
|
## Authorization request scopes. Optional SSO scopes, override if email and profile are not enough (`openid` is implicit).
|
||||||
#SSO_SCOPES="email profile"
|
# SSO_SCOPES="email profile"
|
||||||
|
|
||||||
## Additional authorization url parameters (ex: to obtain a `refresh_token` with Google Auth).
|
## Additional authorization url parameters (ex: to obtain a `refresh_token` with Google Auth).
|
||||||
# SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"
|
# SSO_AUTHORIZE_EXTRA_PARAMS="access_type=offline&prompt=consent"
|
||||||
@@ -571,7 +608,7 @@
|
|||||||
##
|
##
|
||||||
## According to the RFC6238 (https://tools.ietf.org/html/rfc6238),
|
## According to the RFC6238 (https://tools.ietf.org/html/rfc6238),
|
||||||
## we allow by default the TOTP code which was valid one step back and one in the future.
|
## we allow by default the TOTP code which was valid one step back and one in the future.
|
||||||
## This can however allow attackers to be a bit more lucky with there attempts because there are 3 valid codes.
|
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
||||||
## You can disable this, so that only the current TOTP Code is allowed.
|
## You can disable this, so that only the current TOTP Code is allowed.
|
||||||
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
||||||
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
||||||
@@ -611,7 +648,7 @@
|
|||||||
# SMTP_AUTH_MECHANISM=
|
# SMTP_AUTH_MECHANISM=
|
||||||
|
|
||||||
## Server name sent during the SMTP HELO
|
## Server name sent during the SMTP HELO
|
||||||
## By default this value should be is on the machine's hostname,
|
## By default this value should be the machine's hostname,
|
||||||
## but might need to be changed in case it trips some anti-spam filters
|
## but might need to be changed in case it trips some anti-spam filters
|
||||||
# HELO_NAME=
|
# HELO_NAME=
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
# Ignore vendored scripts in GitHub stats
|
# Ignore vendored scripts in GitHub stats
|
||||||
src/static/scripts/* linguist-vendored
|
src/static/scripts/* linguist-vendored
|
||||||
|
|
||||||
|
|||||||
+41
-45
@@ -1,6 +1,10 @@
|
|||||||
name: Build
|
name: Build
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
@@ -14,6 +18,7 @@ on:
|
|||||||
- "diesel.toml"
|
- "diesel.toml"
|
||||||
- "docker/Dockerfile.j2"
|
- "docker/Dockerfile.j2"
|
||||||
- "docker/DockerSettings.yaml"
|
- "docker/DockerSettings.yaml"
|
||||||
|
- "macros/**"
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -27,15 +32,16 @@ on:
|
|||||||
- "diesel.toml"
|
- "diesel.toml"
|
||||||
- "docker/Dockerfile.j2"
|
- "docker/Dockerfile.j2"
|
||||||
- "docker/DockerSettings.yaml"
|
- "docker/DockerSettings.yaml"
|
||||||
|
- "macros/**"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and Test ${{ matrix.channel }}
|
name: Build and Test ${{ matrix.channel }}
|
||||||
permissions:
|
runs-on: ubuntu-24.04
|
||||||
actions: write
|
|
||||||
contents: read
|
|
||||||
# We use Ubuntu 22.04 here because this matches the library versions used within the Debian docker containers
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
# Make warnings errors, this is to prevent warnings slipping through.
|
# Make warnings errors, this is to prevent warnings slipping through.
|
||||||
# This is done globally to prevent rebuilds when the RUSTFLAGS env variable changes.
|
# This is done globally to prevent rebuilds when the RUSTFLAGS env variable changes.
|
||||||
@@ -56,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -65,14 +71,13 @@ jobs:
|
|||||||
# Determine rust-toolchain version
|
# Determine rust-toolchain version
|
||||||
- name: Init Variables
|
- name: Init Variables
|
||||||
id: toolchain
|
id: toolchain
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
CHANNEL: ${{ matrix.channel }}
|
CHANNEL: ${{ matrix.channel }}
|
||||||
run: |
|
run: |
|
||||||
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
||||||
RUST_TOOLCHAIN="$(grep -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)"
|
RUST_TOOLCHAIN="$(grep -m1 -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)"
|
||||||
elif [[ "${CHANNEL}" == 'msrv' ]]; then
|
elif [[ "${CHANNEL}" == 'msrv' ]]; then
|
||||||
RUST_TOOLCHAIN="$(grep -oP 'rust-version.*"(\K.*?)(?=")' Cargo.toml)"
|
RUST_TOOLCHAIN="$(grep -m1 -oP 'rust-version\s.*"(\K.*?)(?=")' Cargo.toml)"
|
||||||
else
|
else
|
||||||
RUST_TOOLCHAIN="${CHANNEL}"
|
RUST_TOOLCHAIN="${CHANNEL}"
|
||||||
fi
|
fi
|
||||||
@@ -80,32 +85,23 @@ jobs:
|
|||||||
# End Determine rust-toolchain version
|
# End Determine rust-toolchain version
|
||||||
|
|
||||||
|
|
||||||
# Only install the clippy and rustfmt components on the default rust-toolchain
|
- name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||||
- name: "Install rust-toolchain version"
|
|
||||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # master @ Apr 29, 2025, 9:22 PM GMT+2
|
|
||||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
|
||||||
with:
|
|
||||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
|
||||||
components: clippy, rustfmt
|
|
||||||
# End Uses the rust-toolchain file to determine version
|
|
||||||
|
|
||||||
|
|
||||||
# Install the any other channel to be used for which we do not execute clippy and rustfmt
|
|
||||||
- name: "Install MSRV version"
|
|
||||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # master @ Apr 29, 2025, 9:22 PM GMT+2
|
|
||||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
|
||||||
with:
|
|
||||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
|
||||||
# End Install the MSRV channel to be used
|
|
||||||
|
|
||||||
# Set the current matrix toolchain version as default
|
|
||||||
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
|
||||||
env:
|
env:
|
||||||
|
CHANNEL: ${{ matrix.channel }}
|
||||||
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||||
run: |
|
run: |
|
||||||
# Remove the rust-toolchain.toml
|
# Remove the rust-toolchain.toml
|
||||||
rm rust-toolchain.toml
|
rm rust-toolchain.toml
|
||||||
# Set the default
|
|
||||||
|
# Install the correct toolchain version
|
||||||
|
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update
|
||||||
|
|
||||||
|
# If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy
|
||||||
|
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
|
||||||
|
rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set as the default toolchain
|
||||||
rustup default "${RUST_TOOLCHAIN}"
|
rustup default "${RUST_TOOLCHAIN}"
|
||||||
|
|
||||||
# Show environment
|
# Show environment
|
||||||
@@ -117,60 +113,60 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
|
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
# Only update when really needed! Use a <year>.<month>[.<inc>] format.
|
# Only update when really needed! Use a <year>.<month>[.<inc>] format.
|
||||||
prefix-key: "v2023.07-rust"
|
prefix-key: "v2025.09-rust"
|
||||||
# End Enable Rust Caching
|
# End Enable Rust Caching
|
||||||
|
|
||||||
# Run cargo tests
|
# Run cargo tests
|
||||||
# First test all features together, afterwards test them separately.
|
# First test all features together, afterwards test them separately.
|
||||||
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,query_logger"
|
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3"
|
||||||
id: test_sqlite_mysql_postgresql_mimalloc_logger
|
id: test_sqlite_mysql_postgresql_mimalloc_s3
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite,mysql,postgresql,enable_mimalloc,query_logger
|
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
||||||
|
|
||||||
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
|
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
|
||||||
id: test_sqlite_mysql_postgresql_mimalloc
|
id: test_sqlite_mysql_postgresql_mimalloc
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite,mysql,postgresql,enable_mimalloc
|
cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc
|
||||||
|
|
||||||
- name: "test features: sqlite,mysql,postgresql"
|
- name: "test features: sqlite,mysql,postgresql"
|
||||||
id: test_sqlite_mysql_postgresql
|
id: test_sqlite_mysql_postgresql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite,mysql,postgresql
|
cargo test --profile ci --features sqlite,mysql,postgresql
|
||||||
|
|
||||||
- name: "test features: sqlite"
|
- name: "test features: sqlite"
|
||||||
id: test_sqlite
|
id: test_sqlite
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features sqlite
|
cargo test --profile ci --features sqlite
|
||||||
|
|
||||||
- name: "test features: mysql"
|
- name: "test features: mysql"
|
||||||
id: test_mysql
|
id: test_mysql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features mysql
|
cargo test --profile ci --features mysql
|
||||||
|
|
||||||
- name: "test features: postgresql"
|
- name: "test features: postgresql"
|
||||||
id: test_postgresql
|
id: test_postgresql
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --features postgresql
|
cargo test --profile ci --features postgresql
|
||||||
# End Run cargo tests
|
# End Run cargo tests
|
||||||
|
|
||||||
|
|
||||||
# Run cargo clippy, and fail on warnings
|
# Run cargo clippy, and fail on warnings
|
||||||
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc"
|
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3"
|
||||||
id: clippy
|
id: clippy
|
||||||
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc
|
cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3
|
||||||
# End Run cargo clippy
|
# End Run cargo clippy
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +184,7 @@ jobs:
|
|||||||
- name: "Some checks failed"
|
- name: "Some checks failed"
|
||||||
if: ${{ failure() }}
|
if: ${{ failure() }}
|
||||||
env:
|
env:
|
||||||
TEST_DB_M_L: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_logger.outcome }}
|
TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }}
|
||||||
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
|
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
|
||||||
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
|
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
|
||||||
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
||||||
@@ -201,13 +197,13 @@ jobs:
|
|||||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,query_logger)|${TEST_DB_M_L}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
name: Check templates
|
name: Check templates
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on: [ push, pull_request ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-templates:
|
docker-templates:
|
||||||
name: Validate docker templates
|
name: Validate docker templates
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
name: Hadolint
|
name: Hadolint
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on: [ push, pull_request ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
hadolint:
|
hadolint:
|
||||||
name: Validate Dockerfile syntax
|
name: Validate Dockerfile syntax
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -24,31 +30,29 @@ jobs:
|
|||||||
driver-opts: |
|
driver-opts: |
|
||||||
network=host
|
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.12.0
|
|
||||||
# End Download hadolint
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# End Checkout the repo
|
# End Checkout the repo
|
||||||
|
|
||||||
# Test Dockerfiles with hadolint
|
# Test Dockerfiles with hadolint
|
||||||
- name: Run hadolint
|
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
|
||||||
shell: bash
|
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
|
||||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
- name: Run hadolint on Dockerfile.debian
|
||||||
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.debian
|
||||||
|
|
||||||
|
- name: Run hadolint on Dockerfile.alpine
|
||||||
|
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||||
|
with:
|
||||||
|
dockerfile: docker/Dockerfile.alpine
|
||||||
# End Test Dockerfiles with hadolint
|
# End Test Dockerfiles with hadolint
|
||||||
|
|
||||||
# Test Dockerfiles with docker build checks
|
# Test Dockerfiles with docker build checks
|
||||||
- name: Run docker build check
|
- name: Run docker build check
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
echo "Checking docker/Dockerfile.debian"
|
echo "Checking docker/Dockerfile.debian"
|
||||||
docker build --check . -f docker/Dockerfile.debian
|
docker build --check . -f docker/Dockerfile.debian
|
||||||
|
|||||||
+233
-167
@@ -1,6 +1,12 @@
|
|||||||
name: Release
|
name: Release
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Apply concurrency control only on the upstream repo
|
||||||
|
group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
|
||||||
|
# Don't cancel other runs when creating a tag
|
||||||
|
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -10,73 +16,55 @@ on:
|
|||||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
||||||
- '[1-2].[0-9]+.[0-9]+'
|
- '[1-2].[0-9]+.[0-9]+'
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# A "release" environment must be created in the repository settings
|
||||||
|
# (Settings > Environments > New environment) with the following
|
||||||
|
# variables and secrets configured as needed.
|
||||||
|
#
|
||||||
|
# Variables (only set the ones for registries you want to push to):
|
||||||
|
# DOCKERHUB_REPO: 'index.docker.io/<user>/<repo>'
|
||||||
|
# QUAY_REPO: 'quay.io/<user>/<repo>'
|
||||||
|
# GHCR_REPO: 'ghcr.io/<user>/<repo>'
|
||||||
|
#
|
||||||
|
# Secrets (only required when the corresponding *_REPO variable is set):
|
||||||
|
# DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
|
||||||
|
# QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN
|
||||||
|
# GITHUB_TOKEN is provided automatically
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# https://github.com/marketplace/actions/skip-duplicate-actions
|
|
||||||
# Some checks to determine if we need to continue with building a new docker.
|
|
||||||
# We will skip this check if we are creating a tag, because that has the same hash as a previous run already.
|
|
||||||
skip_check:
|
|
||||||
# Only run this in the upstream repo and not on forks
|
|
||||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
|
||||||
name: Cancel older jobs when running
|
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Skip Duplicates Actions
|
|
||||||
id: skip_check
|
|
||||||
uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1
|
|
||||||
with:
|
|
||||||
cancel_others: 'true'
|
|
||||||
# Only run this when not creating a tag
|
|
||||||
if: ${{ github.ref_type == 'branch' }}
|
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
needs: skip_check
|
|
||||||
if: ${{ needs.skip_check.outputs.should_skip != 'true' && github.repository == 'dani-garcia/vaultwarden' }}
|
|
||||||
name: Build Vaultwarden containers
|
name: Build Vaultwarden containers
|
||||||
|
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
deployment: false
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write # Needed to upload packages and artifacts
|
||||||
contents: read
|
contents: read
|
||||||
attestations: write
|
attestations: write # Needed to generate an artifact attestation for a build
|
||||||
id-token: write
|
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
|
|
||||||
services:
|
|
||||||
registry:
|
|
||||||
image: registry@sha256:1fc7de654f2ac1247f0b67e8a459e273b0993be7d2beda1f3f56fbf1001ed3e7 # v3.0.0
|
|
||||||
ports:
|
|
||||||
- 5000:5000
|
|
||||||
env:
|
env:
|
||||||
SOURCE_COMMIT: ${{ github.sha }}
|
SOURCE_COMMIT: ${{ github.sha }}
|
||||||
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}"
|
||||||
# The *_REPO variables need to be configured as repository variables
|
|
||||||
# Append `/settings/variables/actions` to your repo url
|
|
||||||
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>'
|
|
||||||
# Check for Docker hub credentials in secrets
|
|
||||||
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
|
|
||||||
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>'
|
|
||||||
# Check for Github credentials in secrets
|
|
||||||
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }}
|
|
||||||
# QUAY_REPO needs to be 'quay.io/<user>/<repo>'
|
|
||||||
# Check for Quay.io credentials in secrets
|
|
||||||
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }}
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
arch: ["amd64", "arm64", "arm/v7", "arm/v6"]
|
||||||
base_image: ["debian","alpine"]
|
base_image: ["debian","alpine"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Initialize QEMU binfmt support
|
- name: Initialize QEMU binfmt support
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||||
with:
|
with:
|
||||||
platforms: "arm64,arm"
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
# Start Docker Buildx
|
# Start Docker Buildx
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
# https://github.com/moby/buildkit/issues/3969
|
# https://github.com/moby/buildkit/issues/3969
|
||||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||||
with:
|
with:
|
||||||
@@ -89,25 +77,24 @@ jobs:
|
|||||||
|
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Determine Base Tags and Source Version
|
# Normalize the architecture string for use in paths and cache keys
|
||||||
- name: Determine Base Tags and Source Version
|
- name: Normalize architecture string
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
MATRIX_ARCH: ${{ matrix.arch }}
|
||||||
run: |
|
run: |
|
||||||
# Check which main tag we are going to build determined by ref_type
|
# Replace slashes with nothing to create a safe string for paths/cache keys
|
||||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
NORMALIZED_ARCH="${MATRIX_ARCH//\/}"
|
||||||
echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_ENV}"
|
echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
|
||||||
echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# Determine Source Version
|
||||||
|
- name: Determine Source Version
|
||||||
|
run: |
|
||||||
# Get the Source Version for this release
|
# Get the Source Version for this release
|
||||||
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||||
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
if [[ -n "${GIT_EXACT_TAG}" ]]; then
|
||||||
@@ -116,19 +103,17 @@ jobs:
|
|||||||
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
|
||||||
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}"
|
||||||
fi
|
fi
|
||||||
# End Determine Base Tags
|
|
||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for DockerHub
|
- name: Add registry for DockerHub
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||||
run: |
|
run: |
|
||||||
@@ -136,16 +121,15 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for ghcr.io
|
- name: Add registry for ghcr.io
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
run: |
|
run: |
|
||||||
@@ -153,64 +137,74 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_TOKEN }}
|
password: ${{ secrets.QUAY_TOKEN }}
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
|
||||||
- name: Add registry for Quay.io
|
- name: Add registry for Quay.io
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||||
run: |
|
run: |
|
||||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Configure build cache from/to
|
- name: Configure build cache from/to
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
BASE_IMAGE: ${{ matrix.base_image }}
|
||||||
|
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||||
run: |
|
run: |
|
||||||
#
|
#
|
||||||
# Check if there is a GitHub Container Registry Login and use it for caching
|
# Check if there is a GitHub Container Registry Login and use it for caching
|
||||||
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
|
if [[ -n "${GHCR_REPO}" ]]; then
|
||||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}" | tee -a "${GITHUB_ENV}"
|
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
|
||||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||||
else
|
else
|
||||||
echo "BAKE_CACHE_FROM="
|
echo "BAKE_CACHE_FROM="
|
||||||
echo "BAKE_CACHE_TO="
|
echo "BAKE_CACHE_TO="
|
||||||
fi
|
fi
|
||||||
#
|
#
|
||||||
|
|
||||||
- name: Add localhost registry
|
- name: Generate tags
|
||||||
shell: bash
|
id: tags
|
||||||
|
env:
|
||||||
|
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||||
run: |
|
run: |
|
||||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
|
# Convert comma-separated list to newline-separated set commands
|
||||||
|
TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|")
|
||||||
|
|
||||||
|
# Output for use in next step
|
||||||
|
{
|
||||||
|
echo "TAGS<<EOF"
|
||||||
|
echo "$TAGS"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@37816e747588cb137173af99ab33873600c46ea8 # v6.8.0
|
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
|
SOURCE_VERSION: "${{ env.SOURCE_VERSION }}"
|
||||||
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
|
SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}"
|
||||||
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
|
||||||
with:
|
with:
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
|
||||||
source: .
|
source: .
|
||||||
files: docker/docker-bake.hcl
|
files: docker/docker-bake.hcl
|
||||||
targets: "${{ matrix.base_image }}-multi"
|
targets: "${{ matrix.base_image }}-multi"
|
||||||
set: |
|
set: |
|
||||||
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
||||||
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
||||||
|
*.platform=linux/${{ matrix.arch }}
|
||||||
|
${{ env.TAGS }}
|
||||||
|
*.output=type=local,dest=./output
|
||||||
|
*.output=type=image,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
- name: Extract digest SHA
|
- name: Extract digest SHA
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
BASE_IMAGE: ${{ matrix.base_image }}
|
||||||
@@ -218,105 +212,177 @@ jobs:
|
|||||||
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
||||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
env:
|
||||||
|
DIGEST_SHA: ${{ env.DIGEST_SHA }}
|
||||||
|
RUNNER_TEMP: ${{ runner.temp }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "${RUNNER_TEMP}"/digests
|
||||||
|
digest="${DIGEST_SHA}"
|
||||||
|
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Rename binaries to match target platform
|
||||||
|
env:
|
||||||
|
NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }}
|
||||||
|
run: |
|
||||||
|
mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}"
|
||||||
|
|
||||||
|
# Upload artifacts to Github Actions and Attest the binaries
|
||||||
|
- name: Attest binaries
|
||||||
|
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||||
|
with:
|
||||||
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
|
- name: Upload binaries as artifacts
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
|
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
|
merge-manifests:
|
||||||
|
name: Merge manifests
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: docker-build
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
deployment: false
|
||||||
|
permissions:
|
||||||
|
packages: write # Needed to upload packages and artifacts
|
||||||
|
attestations: write # Needed to generate an artifact attestation for a build
|
||||||
|
id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
base_image: ["debian","alpine"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digests-*-${{ matrix.base_image }}
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
# Login to Docker Hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for DockerHub
|
||||||
|
if: ${{ vars.DOCKERHUB_REPO != '' }}
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Login to GitHub Container Registry
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for ghcr.io
|
||||||
|
if: ${{ vars.GHCR_REPO != '' }}
|
||||||
|
env:
|
||||||
|
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Login to Quay.io
|
||||||
|
- name: Login to Quay.io
|
||||||
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||||
|
with:
|
||||||
|
registry: quay.io
|
||||||
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
password: ${{ secrets.QUAY_TOKEN }}
|
||||||
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
|
||||||
|
- name: Add registry for Quay.io
|
||||||
|
if: ${{ vars.QUAY_REPO != '' }}
|
||||||
|
env:
|
||||||
|
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||||
|
run: |
|
||||||
|
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# Determine Base Tags
|
||||||
|
- name: Determine Base Tags
|
||||||
|
env:
|
||||||
|
BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}"
|
||||||
|
REF_TYPE: ${{ github.ref_type }}
|
||||||
|
run: |
|
||||||
|
# Check which main tag we are going to build determined by ref_type
|
||||||
|
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||||
|
echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}"
|
||||||
|
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||||
|
echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create manifest list, push it and extract digest SHA
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
env:
|
||||||
|
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||||
|
CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}"
|
||||||
|
run: |
|
||||||
|
IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}"
|
||||||
|
IFS=',' read -ra TAGS <<< "${BASE_TAGS}"
|
||||||
|
|
||||||
|
TAG_ARGS=()
|
||||||
|
for img in "${IMAGES[@]}"; do
|
||||||
|
for tag in "${TAGS[@]}"; do
|
||||||
|
TAG_ARGS+=("-t" "${img}:${tag}")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Creating manifest"
|
||||||
|
if ! OUTPUT=$(docker buildx imagetools create \
|
||||||
|
"${TAG_ARGS[@]}" \
|
||||||
|
$(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then
|
||||||
|
echo "Manifest creation failed"
|
||||||
|
echo "${OUTPUT}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Manifest created successfully"
|
||||||
|
echo "${OUTPUT}"
|
||||||
|
|
||||||
|
# Extract digest SHA for subsequent steps
|
||||||
|
GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)"
|
||||||
|
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||||
|
|
||||||
# Attest container images
|
# Attest container images
|
||||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.GHCR_REPO }}
|
subject-name: ${{ vars.GHCR_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
|
||||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ vars.QUAY_REPO }}
|
subject-name: ${{ vars.QUAY_REPO }}
|
||||||
subject-digest: ${{ env.DIGEST_SHA }}
|
subject-digest: ${{ env.DIGEST_SHA }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
|
||||||
|
|
||||||
# Extract the Alpine binaries from the containers
|
|
||||||
- name: Extract binaries
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
|
||||||
BASE_IMAGE: ${{ matrix.base_image }}
|
|
||||||
run: |
|
|
||||||
# Check which main tag we are going to build determined by ref_type
|
|
||||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
|
||||||
EXTRACT_TAG="latest"
|
|
||||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
|
||||||
EXTRACT_TAG="testing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check which base_image was used and append -alpine if needed
|
|
||||||
if [[ "${BASE_IMAGE}" == "alpine" ]]; then
|
|
||||||
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# After each extraction the image is removed.
|
|
||||||
# This is needed because using different platforms doesn't trigger a new pull/download
|
|
||||||
|
|
||||||
# Extract amd64 binary
|
|
||||||
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp amd64:/vaultwarden vaultwarden-amd64-${BASE_IMAGE}
|
|
||||||
docker rm --force amd64
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract arm64 binary
|
|
||||||
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp arm64:/vaultwarden vaultwarden-arm64-${BASE_IMAGE}
|
|
||||||
docker rm --force arm64
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract armv7 binary
|
|
||||||
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp armv7:/vaultwarden vaultwarden-armv7-${BASE_IMAGE}
|
|
||||||
docker rm --force armv7
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Extract armv6 binary
|
|
||||||
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
docker cp armv6:/vaultwarden vaultwarden-armv6-${BASE_IMAGE}
|
|
||||||
docker rm --force armv6
|
|
||||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
|
||||||
|
|
||||||
# Upload artifacts to Github Actions and Attest the binaries
|
|
||||||
- name: "Upload amd64 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-amd64-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload arm64 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-arm64-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload armv7 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-armv7-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Upload armv6 artifact ${{ matrix.base_image }}"
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
||||||
with:
|
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6-${{ matrix.base_image }}
|
|
||||||
path: vaultwarden-armv6-${{ matrix.base_image }}
|
|
||||||
|
|
||||||
- name: "Attest artifacts ${{ matrix.base_image }}"
|
|
||||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
|
||||||
with:
|
|
||||||
subject-path: vaultwarden-*
|
|
||||||
# End Upload artifacts to Github Actions
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
name: Cleanup
|
name: Cleanup
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -16,7 +20,7 @@ jobs:
|
|||||||
releasecache-cleanup:
|
releasecache-cleanup:
|
||||||
name: Releasecache Cleanup
|
name: Releasecache Cleanup
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write # To be able to cleanup old caches
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
name: Trivy
|
name: Trivy
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -23,20 +27,18 @@ jobs:
|
|||||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||||
name: Trivy Scan
|
name: Trivy Scan
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
security-events: write # To write the security report
|
||||||
actions: read
|
|
||||||
security-events: write
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
env:
|
env:
|
||||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||||
@@ -48,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3.29.4
|
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Code Spell Checking
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typos:
|
||||||
|
name: Run typos spell checking
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Checkout the repo
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
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@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
name: Security Analysis with zizmor
|
name: Security Analysis with zizmor
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,22 +11,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
name: Run zizmor
|
name: Run zizmor
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write # To write the security report
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@f52a838cfabf134edcbaa7c8b3677dde20045018 # v0.1.1
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
+18
-10
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args: ["--fix=no"]
|
args: [ "--fix=no" ]
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
exclude: "(.*js$|.*css$)"
|
exclude: "(.*js$|.*css$)"
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
@@ -15,22 +15,30 @@ repos:
|
|||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
- id: forbid-submodules
|
- id: forbid-submodules
|
||||||
- repo: local
|
|
||||||
|
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||||
|
- repo: https://github.com/crate-ci/typos
|
||||||
|
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
||||||
|
hooks:
|
||||||
|
- id: typos
|
||||||
|
|
||||||
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: fmt
|
- id: fmt
|
||||||
name: fmt
|
name: fmt
|
||||||
description: Format files with cargo fmt.
|
description: Format files with cargo fmt.
|
||||||
entry: cargo fmt
|
entry: cargo fmt
|
||||||
language: system
|
language: system
|
||||||
types: [rust]
|
always_run: true
|
||||||
args: ["--", "--check"]
|
pass_filenames: false
|
||||||
|
args: [ "--", "--check" ]
|
||||||
- id: cargo-test
|
- id: cargo-test
|
||||||
name: cargo test
|
name: cargo test
|
||||||
description: Test the package for errors.
|
description: Test the package for errors.
|
||||||
entry: cargo test
|
entry: cargo test
|
||||||
language: system
|
language: system
|
||||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--"]
|
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
|
||||||
types_or: [rust, file]
|
types_or: [ rust, file ]
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: cargo-clippy
|
- id: cargo-clippy
|
||||||
@@ -38,8 +46,8 @@ repos:
|
|||||||
description: Lint Rust sources
|
description: Lint Rust sources
|
||||||
entry: cargo clippy
|
entry: cargo clippy
|
||||||
language: system
|
language: system
|
||||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--", "-D", "warnings"]
|
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
|
||||||
types_or: [rust, file]
|
types_or: [ rust, file ]
|
||||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: check-docker-templates
|
- id: check-docker-templates
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
[files]
|
||||||
|
extend-exclude = [
|
||||||
|
".git/",
|
||||||
|
"playwright/",
|
||||||
|
"*.js", # Ignore all JavaScript files
|
||||||
|
"!admin*.js", # Except our own JavaScript files
|
||||||
|
]
|
||||||
|
ignore-hidden = false
|
||||||
|
|
||||||
|
[default]
|
||||||
|
extend-ignore-re = [
|
||||||
|
# We use this in place of the reserved type identifier at some places
|
||||||
|
"typ",
|
||||||
|
# In SMTP it's called HELO, so ignore it
|
||||||
|
"(?i)helo_name",
|
||||||
|
"Server name sent during.+HELO",
|
||||||
|
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
||||||
|
"COSEKey",
|
||||||
|
"COSEAlgorithm",
|
||||||
|
# Ignore this specific string as it's valid
|
||||||
|
"Ensure they are valid OTPs",
|
||||||
|
# This word is misspelled upstream
|
||||||
|
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||||
|
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||||
|
"AuthRequestResponseRecieved",
|
||||||
|
# Ignore Punycode/IDN tests
|
||||||
|
"xn--.+"
|
||||||
|
]
|
||||||
Generated
+1573
-1470
File diff suppressed because it is too large
Load Diff
+211
-120
@@ -1,3 +1,10 @@
|
|||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.94.1"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["macros"]
|
members = ["macros"]
|
||||||
|
|
||||||
@@ -5,34 +12,42 @@ members = ["macros"]
|
|||||||
name = "vaultwarden"
|
name = "vaultwarden"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.86.0"
|
|
||||||
resolver = "2"
|
|
||||||
|
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "AGPL-3.0-only"
|
|
||||||
publish = false
|
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
repository.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# default = ["sqlite"]
|
default = [
|
||||||
|
# "sqlite",
|
||||||
|
# "sqlite_system",
|
||||||
|
# "mysql",
|
||||||
|
# "postgresql",
|
||||||
|
]
|
||||||
# Empty to keep compatibility, prefer to set USE_SYSLOG=true
|
# Empty to keep compatibility, prefer to set USE_SYSLOG=true
|
||||||
enable_syslog = []
|
enable_syslog = []
|
||||||
|
# Please enable at least one of these DB backends.
|
||||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||||
sqlite = ["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
|
# Enable to use a vendored and statically linked openssl
|
||||||
vendored_openssl = ["openssl/vendored"]
|
vendored_openssl = ["openssl/vendored"]
|
||||||
# Enable MiMalloc memory allocator to replace the default malloc
|
# Enable MiMalloc memory allocator to replace the default malloc
|
||||||
# This can improve performance for Alpine builds
|
# This can improve performance for Alpine builds
|
||||||
enable_mimalloc = ["dep:mimalloc"]
|
enable_mimalloc = ["dep:mimalloc"]
|
||||||
# This is a development dependency, and should only be used during development!
|
s3 = [
|
||||||
# It enables the usage of the diesel_logger crate, which is able to output the generated queries.
|
"opendal/services-s3",
|
||||||
# You also need to set an env variable `QUERY_LOGGER=1` to fully activate this so you do not have to re-compile
|
"dep:aws-config",
|
||||||
# if you want to turn off the logging for a specific run.
|
"dep:aws-credential-types",
|
||||||
query_logger = ["dep:diesel_logger"]
|
"dep:aws-smithy-runtime-api",
|
||||||
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"]
|
"dep:http",
|
||||||
|
"dep:reqsign-aws-v4",
|
||||||
|
"dep:reqsign-core",
|
||||||
|
]
|
||||||
|
|
||||||
# OIDC specific features
|
# OIDC specific features
|
||||||
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
|
||||||
@@ -50,158 +65,211 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.27"
|
log = "0.4.33"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
||||||
tracing = { version = "0.1.41", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
|
||||||
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
|
|
||||||
# A `dotenv` implementation for Rust
|
# A `dotenv` implementation for Rust
|
||||||
dotenvy = { version = "0.15.7", default-features = false }
|
dotenvy = { version = "0.15.7", default-features = false }
|
||||||
|
|
||||||
# Lazy initialization
|
|
||||||
once_cell = "1.21.3"
|
|
||||||
|
|
||||||
# Numerical libraries
|
# Numerical libraries
|
||||||
num-traits = "0.2.19"
|
num-traits = "0.2.19"
|
||||||
num-derive = "0.4.2"
|
num-derive = "0.4.2"
|
||||||
bigdecimal = "0.4.8"
|
bigdecimal = "0.4.10"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version ="0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
rmpv = "1.3.0" # MessagePack library
|
rmpv = "1.3.1" # MessagePack library
|
||||||
|
|
||||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.2.1"
|
||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.31"
|
futures = "0.3.32"
|
||||||
tokio = { version = "1.47.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
tokio = { version = "1.52.3", features = [
|
||||||
tokio-util = { version = "0.7.15", features = ["compat"]}
|
"fs",
|
||||||
|
"io-util",
|
||||||
|
"net",
|
||||||
|
"parking_lot",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"signal",
|
||||||
|
"time",
|
||||||
|
] }
|
||||||
|
tokio-util = { version = "0.7.18", features = ["compat"] }
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.141"
|
serde_json = "1.0.150"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.2.12", features = ["chrono", "r2d2", "numeric"] }
|
diesel = { version = "2.3.10", features = ["chrono", "r2d2", "numeric"] }
|
||||||
diesel_migrations = "2.2.0"
|
diesel_migrations = "2.3.2"
|
||||||
diesel_logger = { version = "0.4.0", optional = true }
|
|
||||||
|
|
||||||
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
derive_more = { version = "2.1.1", features = [
|
||||||
diesel-derive-newtype = "2.1.2"
|
"as_ref",
|
||||||
|
"deref",
|
||||||
|
"display",
|
||||||
|
"from",
|
||||||
|
"into",
|
||||||
|
] }
|
||||||
|
diesel-derive-newtype = "2.1.3"
|
||||||
|
|
||||||
# Bundled/Static SQLite
|
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
|
||||||
libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
|
libsqlite3-sys = { version = "0.37.0", optional = true }
|
||||||
|
|
||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.9.2"
|
rand = "0.10.2"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
|
rustls = { version = "0.23.41", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.17.0", features = ["v4"] }
|
uuid = { version = "1.23.4", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.41", features = ["clock", "serde"], default-features = false }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
time = "0.3.41"
|
time = "0.3.53"
|
||||||
|
|
||||||
# Job scheduler
|
# Job scheduler
|
||||||
job_scheduler_ng = "2.2.0"
|
job_scheduler_ng = "2.4.0"
|
||||||
|
|
||||||
# Data encoding library Hex/Base32/Base64
|
# Data encoding library Hex/Base32/Base64
|
||||||
data-encoding = "2.9.0"
|
data-encoding = "2.11.0"
|
||||||
|
|
||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] }
|
||||||
|
|
||||||
# TOTP library
|
# TOTP library
|
||||||
totp-lite = "2.0.1"
|
totp-lite = "2.0.1"
|
||||||
|
|
||||||
# Yubico Library
|
# Yubico Library
|
||||||
yubico = { package = "yubico_ng", version = "0.13.0", features = ["online-tokio"], default-features = false }
|
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] }
|
||||||
|
|
||||||
# WebAuthn libraries
|
# WebAuthn libraries
|
||||||
# danger-allow-state-serialisation is needed to save the state in the db
|
# danger-allow-state-serialisation is needed to save the state in the db
|
||||||
# danger-credential-internals is needed to support U2F to Webauthn migration
|
# danger-credential-internals is needed to support U2F to Webauthn migration
|
||||||
# danger-user-presence-only-security-keys is needed to disable UV
|
webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals", "danger-user-presence-only-security-keys"] }
|
webauthn-rs-proto = "0.5.5"
|
||||||
webauthn-rs-proto = "0.5.2"
|
webauthn-rs-core = "0.5.5"
|
||||||
webauthn-rs-core = "0.5.2"
|
|
||||||
|
|
||||||
# Handling of URL's for WebAuthn and favicons
|
# Handling of URL's for WebAuthn and favicons
|
||||||
url = "2.5.4"
|
url = "2.5.8"
|
||||||
|
|
||||||
# Email libraries
|
# Email libraries
|
||||||
lettre = { version = "0.11.18", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
|
lettre = { version = "0.11.22", default-features = false, features = [
|
||||||
percent-encoding = "2.3.1" # URL encoding library used for URL's in the emails
|
# 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"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
# HTML Template library
|
# HTML Template library
|
||||||
handlebars = { version = "6.3.2", features = ["dir_source"] }
|
handlebars = { version = "6.4.2", features = ["dir_source"] }
|
||||||
|
|
||||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||||
reqwest = { version = "0.12.22", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
|
reqwest = { version = "0.13.4", default-features = false, features = [
|
||||||
hickory-resolver = "0.25.2"
|
# 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
|
# Favicon extraction libraries
|
||||||
html5gum = "0.7.0"
|
html5gum = "0.8.4"
|
||||||
regex = { version = "1.11.1", features = ["std", "perf", "unicode-perl"], default-features = false }
|
regex = { version = "1.12.4", default-features = false, features = [
|
||||||
data-url = "0.3.1"
|
"perf",
|
||||||
bytes = "1.10.1"
|
"std",
|
||||||
svg-hush = "0.9.5"
|
"unicode-perl",
|
||||||
|
] }
|
||||||
|
data-url = "0.3.2"
|
||||||
|
bytes = "1.12.1"
|
||||||
|
svg-hush = "0.9.6"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "0.56.0", features = ["async"] }
|
cached = { version = "2.0.2", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
cookie_store = "0.21.1"
|
cookie_store = "0.22.1"
|
||||||
|
|
||||||
# Used by U2F, JWT and PostgreSQL
|
# Used by U2F, JWT and PostgreSQL
|
||||||
openssl = "0.10.73"
|
openssl = "0.10.81"
|
||||||
|
|
||||||
# CLI argument parsing
|
# CLI argument parsing
|
||||||
pico-args = "0.5.0"
|
pico-args = "0.5.0"
|
||||||
|
|
||||||
# Macro ident concatenation
|
# Macro ident concatenation
|
||||||
pastey = "0.1.0"
|
pastey = "0.2.3"
|
||||||
governor = "0.10.0"
|
governor = "0.10.4"
|
||||||
|
|
||||||
|
# CIDR parsing for the trusted proxies of the client IP header
|
||||||
|
ipnet = "2.12.0"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.0.1", features = ["reqwest", "native-tls"] }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
mini-moka = "0.10.2"
|
moka = { version = "0.12.15", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
semver = "1.0.26"
|
semver = "1.0.28"
|
||||||
|
|
||||||
# Allow overriding the default memory allocator
|
# Allow overriding the default memory allocator
|
||||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||||
mimalloc = { version = "0.1.47", 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.4"
|
||||||
|
|
||||||
# Argon2 library with support for the PHC format
|
# Argon2 library with support for the PHC format
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
|
|
||||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||||
rpassword = "7.4.0"
|
rpassword = "7.5.4"
|
||||||
|
|
||||||
# Loading a dynamic CSS Stylesheet
|
# Loading a dynamic CSS Stylesheet
|
||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.54.0", features = ["services-fs"], default-features = false }
|
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
|
||||||
|
|
||||||
# For retrieving AWS credentials, including temporary SSO credentials
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
anyhow = { version = "1.0.98", optional = true }
|
aws-config = { version = "1.8.18", optional = true, default-features = false, features = [
|
||||||
aws-config = { version = "1.8.3", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
"behavior-version-latest",
|
||||||
aws-credential-types = { version = "1.2.4", optional = true }
|
"credentials-process",
|
||||||
aws-smithy-runtime-api = { version = "1.8.5", optional = true }
|
"rt-tokio",
|
||||||
http = { version = "1.3.1", optional = true }
|
"sso",
|
||||||
reqsign = { version = "0.16.5", optional = true }
|
] }
|
||||||
|
aws-credential-types = { version = "1.2.14", optional = true }
|
||||||
|
aws-smithy-runtime-api = { version = "1.13.0", optional = true }
|
||||||
|
http = { version = "1.4.2", optional = true }
|
||||||
|
reqsign-aws-v4 = { version = "3.0.1", optional = true }
|
||||||
|
reqsign-core = { version = "3.0.1", optional = true }
|
||||||
|
|
||||||
# Strip debuginfo from the release builds
|
# Strip debuginfo from the release builds
|
||||||
# The debug symbols are to provide better panic traces
|
# The debug symbols are to provide better panic traces
|
||||||
@@ -210,23 +278,13 @@ reqsign = { version = "0.16.5", optional = true }
|
|||||||
strip = "debuginfo"
|
strip = "debuginfo"
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
debug = false
|
||||||
# A little bit of a speedup
|
|
||||||
[profile.dev]
|
|
||||||
split-debuginfo = "unpacked"
|
|
||||||
|
|
||||||
# Always build argon2 using opt-level 3
|
|
||||||
# This is a huge speed improvement during testing
|
|
||||||
[profile.dev.package.argon2]
|
|
||||||
opt-level = 3
|
|
||||||
|
|
||||||
# Optimize for size
|
# Optimize for size
|
||||||
[profile.release-micro]
|
[profile.release-micro]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
opt-level = "z"
|
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
lto = "fat"
|
opt-level = "z"
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
# Profile for systems with low resources
|
# Profile for systems with low resources
|
||||||
@@ -237,6 +295,32 @@ strip = "symbols"
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 16
|
codegen-units = 16
|
||||||
|
|
||||||
|
# Used for profiling and debugging like valgrind or heaptrack
|
||||||
|
# Inherits release to be sure all optimizations have been done
|
||||||
|
[profile.dbg]
|
||||||
|
inherits = "release"
|
||||||
|
strip = "none"
|
||||||
|
split-debuginfo = "off"
|
||||||
|
debug = "full"
|
||||||
|
|
||||||
|
# A little bit of a speedup for generic building
|
||||||
|
[profile.dev]
|
||||||
|
split-debuginfo = "unpacked"
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# Used for CI builds to improve compile time
|
||||||
|
[profile.ci]
|
||||||
|
inherits = "dev"
|
||||||
|
debug = false
|
||||||
|
debug-assertions = false
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# Always build argon2 using opt-level 3
|
||||||
|
# This is a huge speed improvement during testing
|
||||||
|
[profile.dev.package.argon2]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
# Linting config
|
# Linting config
|
||||||
# https://doc.rust-lang.org/rustc/lints/groups.html
|
# https://doc.rust-lang.org/rustc/lints/groups.html
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
@@ -245,67 +329,74 @@ unsafe_code = "forbid"
|
|||||||
non_ascii_idents = "forbid"
|
non_ascii_idents = "forbid"
|
||||||
|
|
||||||
# Deny
|
# Deny
|
||||||
deprecated_in_future = "deny"
|
warnings = "deny" # Explicitly deny all warnings since we deny all warnings in the end
|
||||||
|
|
||||||
|
# Deny lint groups
|
||||||
|
deprecated_safe = { level = "deny", priority = -1 }
|
||||||
future_incompatible = { level = "deny", priority = -1 }
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
keyword_idents = { level = "deny", priority = -1 }
|
keyword_idents = { level = "deny", priority = -1 }
|
||||||
let_underscore = { level = "deny", priority = -1 }
|
let_underscore = { level = "deny", priority = -1 }
|
||||||
noop_method_call = "deny"
|
nonstandard_style = { level = "deny", priority = -1 }
|
||||||
refining_impl_trait = { level = "deny", priority = -1 }
|
refining_impl_trait = { level = "deny", priority = -1 }
|
||||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
unused = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
# Deny individual lints
|
||||||
|
closure_returning_async_block = "deny"
|
||||||
|
deprecated_in_future = "deny"
|
||||||
single_use_lifetimes = "deny"
|
single_use_lifetimes = "deny"
|
||||||
trivial_casts = "deny"
|
trivial_casts = "deny"
|
||||||
trivial_numeric_casts = "deny"
|
trivial_numeric_casts = "deny"
|
||||||
unused = { level = "deny", priority = -1 }
|
|
||||||
unused_import_braces = "deny"
|
unused_import_braces = "deny"
|
||||||
unused_lifetimes = "deny"
|
unused_lifetimes = "deny"
|
||||||
unused_qualifications = "deny"
|
unused_qualifications = "deny"
|
||||||
variant_size_differences = "deny"
|
variant_size_differences = "deny"
|
||||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
|
||||||
# Building Vaultwarden with Rust v1.85.0 and edition 2024 also works without issues
|
|
||||||
if_let_rescope = "allow"
|
|
||||||
tail_expr_drop_order = "allow"
|
|
||||||
|
|
||||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
# Warn
|
# Warn only so you can still use these during development, but not in the final code
|
||||||
dbg_macro = "warn"
|
dbg_macro = "warn"
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
|
|
||||||
# Ignore/Allow
|
# Ignore/Allow
|
||||||
result_large_err = "allow"
|
result_large_err = "allow"
|
||||||
|
|
||||||
# Deny
|
# Warn on these lint group (Some might be warn by default already though)
|
||||||
case_sensitive_file_extension_comparisons = "deny"
|
# Will be denied during CI!
|
||||||
cast_lossless = "deny"
|
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"
|
||||||
clone_on_ref_ptr = "deny"
|
clone_on_ref_ptr = "deny"
|
||||||
equatable_if_let = "deny"
|
equatable_if_let = "deny"
|
||||||
filter_map_next = "deny"
|
|
||||||
float_cmp_const = "deny"
|
float_cmp_const = "deny"
|
||||||
inefficient_to_string = "deny"
|
|
||||||
iter_on_empty_collections = "deny"
|
iter_on_empty_collections = "deny"
|
||||||
iter_on_single_items = "deny"
|
iter_on_single_items = "deny"
|
||||||
linkedlist = "deny"
|
|
||||||
macro_use_imports = "deny"
|
|
||||||
manual_assert = "deny"
|
|
||||||
manual_instant_elapsed = "deny"
|
|
||||||
manual_string_new = "deny"
|
|
||||||
match_wildcard_for_single_variants = "deny"
|
|
||||||
mem_forget = "deny"
|
mem_forget = "deny"
|
||||||
needless_continue = "deny"
|
needless_collect = "deny"
|
||||||
needless_lifetimes = "deny"
|
redundant_clone = "deny"
|
||||||
option_option = "deny"
|
|
||||||
string_add_assign = "deny"
|
|
||||||
string_to_string = "deny"
|
|
||||||
unnecessary_join = "deny"
|
|
||||||
unnecessary_self_imports = "deny"
|
unnecessary_self_imports = "deny"
|
||||||
unnested_or_patterns = "deny"
|
useless_let_if_seq = "deny"
|
||||||
unused_async = "deny"
|
|
||||||
unused_self = "deny"
|
|
||||||
verbose_file_reads = "deny"
|
verbose_file_reads = "deny"
|
||||||
zero_sized_map_values = "deny"
|
str_to_string = "deny"
|
||||||
|
|
||||||
|
# Pedantic Opt-Outs
|
||||||
|
inline_always = "allow" # We use this sparsely
|
||||||
|
struct_field_names = "allow" # Noisy and some items are Bitwarden controlled
|
||||||
|
large_futures = "allow" # Causes a fail in some Rocket macro's, since we experience no issues, allow it
|
||||||
|
too_many_lines = "allow" # For now, allow this, good to enable in the future and see if we can refactor
|
||||||
|
unnecessary_wraps = "allow" # Too much false positives because of Rocket integrations
|
||||||
|
# We do not use these doc items
|
||||||
|
doc_link_with_quotes = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!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).
|
> 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 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).
|
> 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).
|
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.
|
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,30 +1,26 @@
|
|||||||
use std::env;
|
use std::{env, io::Error, process::Command};
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros
|
// These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one.
|
||||||
println!("cargo:rustc-cfg=sqlite");
|
println!("cargo:rustc-cfg=sqlite");
|
||||||
#[cfg(feature = "mysql")]
|
#[cfg(feature = "mysql")]
|
||||||
println!("cargo:rustc-cfg=mysql");
|
println!("cargo:rustc-cfg=mysql");
|
||||||
#[cfg(feature = "postgresql")]
|
#[cfg(feature = "postgresql")]
|
||||||
println!("cargo:rustc-cfg=postgresql");
|
println!("cargo:rustc-cfg=postgresql");
|
||||||
#[cfg(feature = "query_logger")]
|
#[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))]
|
||||||
println!("cargo:rustc-cfg=query_logger");
|
|
||||||
#[cfg(feature = "s3")]
|
|
||||||
println!("cargo:rustc-cfg=s3");
|
|
||||||
|
|
||||||
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
|
|
||||||
compile_error!(
|
compile_error!(
|
||||||
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
|
"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,
|
// Use check-cfg to let cargo know which cfg's we define,
|
||||||
// and avoid warnings when they are used in the code.
|
// and avoid warnings when they are used in the code.
|
||||||
println!("cargo::rustc-check-cfg=cfg(sqlite)");
|
println!("cargo::rustc-check-cfg=cfg(sqlite)");
|
||||||
println!("cargo::rustc-check-cfg=cfg(mysql)");
|
println!("cargo::rustc-check-cfg=cfg(mysql)");
|
||||||
println!("cargo::rustc-check-cfg=cfg(postgresql)");
|
println!("cargo::rustc-check-cfg=cfg(postgresql)");
|
||||||
println!("cargo::rustc-check-cfg=cfg(query_logger)");
|
|
||||||
println!("cargo::rustc-check-cfg=cfg(s3)");
|
println!("cargo::rustc-check-cfg=cfg(s3)");
|
||||||
|
|
||||||
// Rerun when these paths are changed.
|
// Rerun when these paths are changed.
|
||||||
@@ -34,9 +30,6 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=.git/index");
|
println!("cargo:rerun-if-changed=.git/index");
|
||||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||||
|
|
||||||
#[cfg(all(not(debug_assertions), feature = "query_logger"))]
|
|
||||||
compile_error!("Query Logging is only allowed during development, it is not intended for production usage!");
|
|
||||||
|
|
||||||
// Support $BWRS_VERSION for legacy compatibility, but default to $VW_VERSION.
|
// Support $BWRS_VERSION for legacy compatibility, but default to $VW_VERSION.
|
||||||
// If neither exist, read from git.
|
// If neither exist, read from git.
|
||||||
let maybe_vaultwarden_version =
|
let maybe_vaultwarden_version =
|
||||||
@@ -48,13 +41,12 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
fn run(args: &[&str]) -> Result<String, Error> {
|
||||||
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
use std::io::Error;
|
|
||||||
return Err(Error::other("Command not successful"));
|
return Err(Error::other("Command not successful"));
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
|
Ok(String::from_utf8(out.stdout).unwrap().trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This method reads info from Git, namely tags, branch, and revision
|
/// This method reads info from Git, namely tags, branch, and revision
|
||||||
@@ -64,7 +56,7 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
|||||||
/// - `env!("GIT_BRANCH")`
|
/// - `env!("GIT_BRANCH")`
|
||||||
/// - `env!("GIT_REV")`
|
/// - `env!("GIT_REV")`
|
||||||
/// - `env!("VW_VERSION")`
|
/// - `env!("VW_VERSION")`
|
||||||
fn version_from_git_info() -> Result<String, std::io::Error> {
|
fn version_from_git_info() -> Result<String, Error> {
|
||||||
// The exact tag for the current commit, can be empty when
|
// The exact tag for the current commit, can be empty when
|
||||||
// the current commit doesn't have an associated tag
|
// the current commit doesn't have an associated tag
|
||||||
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2025.7.0"
|
vault_version: "v2026.6.2"
|
||||||
vault_image_digest: "sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e"
|
vault_image_digest: "sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a"
|
||||||
# Cross Compile Docker Helper Scripts v1.6.1
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
rust_version: 1.88.0 # Rust version to be used
|
rust_version: 1.96.1 # Rust version to be used
|
||||||
debian_version: bookworm # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
alpine_version: "3.22" # Alpine version to be used
|
alpine_version: "3.24" # Alpine version to be used
|
||||||
# For which platforms/architectures will we try to build images
|
# For which platforms/architectures will we try to build images
|
||||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||||
# Determine the build images per OS/Arch
|
# Determine the build images per OS/Arch
|
||||||
@@ -17,7 +17,6 @@ build_stage_image:
|
|||||||
platform: "$BUILDPLATFORM"
|
platform: "$BUILDPLATFORM"
|
||||||
alpine:
|
alpine:
|
||||||
image: "build_${TARGETARCH}${TARGETVARIANT}"
|
image: "build_${TARGETARCH}${TARGETVARIANT}"
|
||||||
platform: "linux/amd64" # The Alpine build images only have linux/amd64 images
|
|
||||||
arch_image:
|
arch_image:
|
||||||
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
|
amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}"
|
||||||
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
|
arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}"
|
||||||
|
|||||||
+16
-17
@@ -19,27 +19,27 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.7.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.7.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e]
|
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
|
||||||
# [docker.io/vaultwarden/web-vault:v2025.7.0]
|
# [docker.io/vaultwarden/web-vault:v2026.6.2]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.88.0 AS build_amd64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.96.1 AS build_amd64
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.88.0 AS build_arm64
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.96.1 AS build_arm64
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.88.0 AS build_armv7
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.96.1 AS build_armv7
|
||||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.88.0 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.96.1 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build
|
FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
@@ -53,10 +53,9 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
TERM=xterm-256color \
|
TERM=xterm-256color \
|
||||||
CARGO_HOME="/root/.cargo" \
|
CARGO_HOME="/root/.cargo" \
|
||||||
USER="root" \
|
USER="root" \
|
||||||
# Use PostgreSQL v15 during Alpine/MUSL builds instead of the default v11
|
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
||||||
# Debian Bookworm already contains libpq v15
|
# Debian Trixie uses libpq v17
|
||||||
PQ_LIB_DIR="/usr/local/musl/pq15/lib"
|
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||||
|
|
||||||
|
|
||||||
# Create CARGO_HOME folder and don't download rust docs
|
# Create CARGO_HOME folder and don't download rust docs
|
||||||
RUN mkdir -pv "${CARGO_HOME}" && \
|
RUN mkdir -pv "${CARGO_HOME}" && \
|
||||||
@@ -127,7 +126,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.22
|
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
ROCKET_ADDRESS=0.0.0.0 \
|
ROCKET_ADDRESS=0.0.0.0 \
|
||||||
|
|||||||
+22
-49
@@ -19,24 +19,24 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2025.7.0
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.2
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.7.0
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e]
|
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a
|
||||||
# [docker.io/vaultwarden/web-vault:v2025.7.0]
|
# [docker.io/vaultwarden/web-vault:v2026.6.2]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f6ac819a2cd9e226f2cd2ec26196ede94a41e672e9672a11b5f307a19278b15e AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
## And these bash scripts do not have any significant difference if at all
|
## And these bash scripts do not have any significant difference if at all
|
||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894 AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.88.0-slim-bookworm AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.96.1-slim-trixie AS build
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG TARGETVARIANT
|
ARG TARGETVARIANT
|
||||||
@@ -51,8 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
TERM=xterm-256color \
|
TERM=xterm-256color \
|
||||||
CARGO_HOME="/root/.cargo" \
|
CARGO_HOME="/root/.cargo" \
|
||||||
USER="root"
|
USER="root"
|
||||||
|
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||||
# Install clang to get `xx-cargo` working
|
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
# Install the libc cross packages based upon the debian-arch
|
# Install the libc cross packages based upon the debian-arch
|
||||||
@@ -60,23 +59,16 @@ RUN apt-get update && \
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
clang \
|
clang \
|
||||||
pkg-config \
|
git && \
|
||||||
git \
|
|
||||||
"libc6-$(xx-info debian-arch)-cross" \
|
|
||||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
|
||||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
|
||||||
xx-apt-get install -y \
|
xx-apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
gcc \
|
|
||||||
libmariadb3 \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
zlib1g-dev && \
|
libmariadb-dev \
|
||||||
# Force install arch dependend mariadb dev packages
|
pkg-config \
|
||||||
# Installing them the normal way breaks several other packages (again)
|
zlib1g-dev \
|
||||||
apt-get download "libmariadb-dev-compat:$(xx-info debian-arch)" "libmariadb-dev:$(xx-info debian-arch)" && \
|
xx-c-essentials && \
|
||||||
dpkg --force-all -i ./libmariadb-dev*.deb && \
|
|
||||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
# 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
|
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||||
|
|
||||||
@@ -88,29 +80,6 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
|||||||
RUN USER=root cargo new --bin /app
|
RUN USER=root cargo new --bin /app
|
||||||
WORKDIR /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 source /env-cargo && \
|
||||||
rustup target add "${CARGO_TARGET}"
|
rustup target add "${CARGO_TARGET}"
|
||||||
|
|
||||||
@@ -127,7 +96,9 @@ ARG DB=sqlite,mysql,postgresql
|
|||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN source /env-cargo && \
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
# Workaround for xx related build issues
|
||||||
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
# Copies the complete project
|
# Copies the complete project
|
||||||
@@ -142,7 +113,9 @@ RUN source /env-cargo && \
|
|||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
# Workaround for xx related build issues
|
||||||
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
@@ -166,7 +139,7 @@ RUN source /env-cargo && \
|
|||||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||||
#
|
#
|
||||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||||
FROM --platform=$TARGETPLATFORM docker.io/library/debian:bookworm-slim
|
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
ENV ROCKET_PROFILE="release" \
|
ENV ROCKET_PROFILE="release" \
|
||||||
ROCKET_ADDRESS=0.0.0.0 \
|
ROCKET_ADDRESS=0.0.0.0 \
|
||||||
@@ -179,7 +152,7 @@ RUN mkdir /data && \
|
|||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
libmariadb-dev-compat \
|
libmariadb3 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
openssl && \
|
openssl && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
|
|||||||
+31
-50
@@ -19,14 +19,19 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version }}
|
# $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||||
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version }}
|
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}
|
||||||
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
# [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||||
# [docker.io/vaultwarden/web-vault:{{ vault_version }}]
|
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
|
||||||
#
|
#
|
||||||
|
{% macro xx_cargo_config() -%}
|
||||||
|
# Workaround for xx related build issues
|
||||||
|
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
|
||||||
|
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
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
@@ -36,16 +41,16 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_diges
|
|||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
||||||
{% elif base == "alpine" %}
|
{% elif base == "alpine" %}
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
{% for arch in build_stage_image[base].arch_image %}
|
{% for arch in build_stage_image[base].arch_image %}
|
||||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build
|
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -63,14 +68,13 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
CARGO_HOME="/root/.cargo" \
|
CARGO_HOME="/root/.cargo" \
|
||||||
USER="root"
|
USER="root"
|
||||||
{%- if base == "alpine" %} \
|
{%- if base == "alpine" %} \
|
||||||
# Use PostgreSQL v15 during Alpine/MUSL builds instead of the default v11
|
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
|
||||||
# Debian Bookworm already contains libpq v15
|
# Debian Trixie uses libpq v17
|
||||||
PQ_LIB_DIR="/usr/local/musl/pq15/lib"
|
PQ_LIB_DIR="/usr/local/musl/pq17/lib"
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "debian" %}
|
||||||
|
# Install clang && xx-c-essentials to get `xx-cargo` working
|
||||||
# Install clang to get `xx-cargo` working
|
|
||||||
# Install pkg-config to allow amd64 builds to find all libraries
|
# Install pkg-config to allow amd64 builds to find all libraries
|
||||||
# Install git so build.rs can determine the correct version
|
# Install git so build.rs can determine the correct version
|
||||||
# Install the libc cross packages based upon the debian-arch
|
# Install the libc cross packages based upon the debian-arch
|
||||||
@@ -78,23 +82,16 @@ RUN apt-get update && \
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
clang \
|
clang \
|
||||||
pkg-config \
|
git && \
|
||||||
git \
|
|
||||||
"libc6-$(xx-info debian-arch)-cross" \
|
|
||||||
"libc6-dev-$(xx-info debian-arch)-cross" \
|
|
||||||
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
|
|
||||||
xx-apt-get install -y \
|
xx-apt-get install -y \
|
||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
gcc \
|
|
||||||
libmariadb3 \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
zlib1g-dev && \
|
libmariadb-dev \
|
||||||
# Force install arch dependend mariadb dev packages
|
pkg-config \
|
||||||
# Installing them the normal way breaks several other packages (again)
|
zlib1g-dev \
|
||||||
apt-get download "libmariadb-dev-compat:$(xx-info debian-arch)" "libmariadb-dev:$(xx-info debian-arch)" && \
|
xx-c-essentials && \
|
||||||
dpkg --force-all -i ./libmariadb-dev*.deb && \
|
|
||||||
# Run xx-cargo early, since it sometimes seems to break when run at a later stage
|
# 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
|
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -107,31 +104,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
|
|||||||
RUN USER=root cargo new --bin /app
|
RUN USER=root cargo new --bin /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
{% if base == "debian" %}
|
{% if base == "alpine" %}
|
||||||
# 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" %}
|
|
||||||
# Environment variables for Cargo on Alpine based builds
|
# Environment variables for Cargo on Alpine based builds
|
||||||
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
|
||||||
# Output the current contents of the file
|
# Output the current contents of the file
|
||||||
@@ -159,7 +132,11 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
|
|||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
# This folder contains the compiled dependencies
|
# This folder contains the compiled dependencies
|
||||||
RUN source /env-cargo && \
|
RUN source /env-cargo && \
|
||||||
|
{% if base == "debian" %}
|
||||||
|
{{ xx_cargo_config() }} && \
|
||||||
|
{% elif base == "alpine" %}
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
|
{% endif %}
|
||||||
find . -not -path "./target*" -delete
|
find . -not -path "./target*" -delete
|
||||||
|
|
||||||
# Copies the complete project
|
# Copies the complete project
|
||||||
@@ -174,7 +151,11 @@ RUN source /env-cargo && \
|
|||||||
# Also do this for build.rs to ensure the version is rechecked
|
# Also do this for build.rs to ensure the version is rechecked
|
||||||
touch build.rs src/main.rs && \
|
touch build.rs src/main.rs && \
|
||||||
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
# Create a symlink to the binary target folder to easy copy the binary in the final stage
|
||||||
|
{% if base == "debian" %}
|
||||||
|
{{ xx_cargo_config() }} && \
|
||||||
|
{% elif base == "alpine" %}
|
||||||
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
|
||||||
|
{% endif %}
|
||||||
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
|
||||||
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
|
||||||
else \
|
else \
|
||||||
@@ -216,7 +197,7 @@ RUN mkdir /data && \
|
|||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
libmariadb-dev-compat \
|
libmariadb3 \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
openssl && \
|
openssl && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
|
|||||||
+2
-2
@@ -116,7 +116,7 @@ docker/bake.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br>
|
You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.<br>
|
||||||
This will also append those values to the tag so you can see the builded container when running `docker images`.
|
This will also append those values to the tag so you can see the built container when running `docker images`.
|
||||||
|
|
||||||
You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use.
|
You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use.
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +162,7 @@ You can append extra arguments after the target if you want. This can be useful
|
|||||||
|
|
||||||
For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br>
|
For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.<br>
|
||||||
|
|
||||||
### Testing podman builded images
|
### Testing podman built images
|
||||||
|
|
||||||
The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that.
|
The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that.
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "macros"
|
name = "macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
repository.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "macros"
|
name = "macros"
|
||||||
@@ -9,8 +13,8 @@ path = "src/lib.rs"
|
|||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.40"
|
quote = "1.0.46"
|
||||||
syn = "2.0.104"
|
syn = "2.0.118"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+5
-4
@@ -1,14 +1,15 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
|
use syn::{DeriveInput, parse_macro_input};
|
||||||
|
|
||||||
#[proc_macro_derive(UuidFromParam)]
|
#[proc_macro_derive(UuidFromParam)]
|
||||||
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_uuid_macro(&ast)
|
impl_derive_uuid_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_uuid_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
@@ -30,12 +31,12 @@ fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
|||||||
|
|
||||||
#[proc_macro_derive(IdFromParam)]
|
#[proc_macro_derive(IdFromParam)]
|
||||||
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
||||||
let ast = syn::parse(input).unwrap();
|
let ast = parse_macro_input!(input as DeriveInput);
|
||||||
|
|
||||||
impl_derive_safestring_macro(&ast)
|
impl_derive_safestring_macro(&ast)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
fn impl_derive_safestring_macro(ast: &DeriveInput) -> TokenStream {
|
||||||
let name = &ast.ident;
|
let name = &ast.ident;
|
||||||
let gen_derive = quote! {
|
let gen_derive = quote! {
|
||||||
#[automatically_derived]
|
#[automatically_derived]
|
||||||
|
|||||||
@@ -1,2 +1,15 @@
|
|||||||
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`;
|
-- Dynamically create DROP FOREIGN KEY
|
||||||
|
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
|
||||||
|
-- This checks if the key exists, and if so, will drop it.
|
||||||
|
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
|
||||||
|
CONSTRAINT_SCHEMA = DATABASE() AND
|
||||||
|
TABLE_NAME = 'sso_users' AND
|
||||||
|
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
|
||||||
|
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
|
||||||
|
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @drop_sso_fk;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_auth;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_auth (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
client_challenge TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
code_response TEXT,
|
||||||
|
auth_response TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
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;
|
||||||
@@ -39,7 +39,7 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
######################
|
######################
|
||||||
ROCKET_ADDRESS=0.0.0.0
|
ROCKET_ADDRESS=0.0.0.0
|
||||||
ROCKET_PORT=8000
|
ROCKET_PORT=8000
|
||||||
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
DOMAIN=http://localhost:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
I_REALLY_WANT_VOLATILE_STORAGE=true
|
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||||
|
|
||||||
|
|||||||
+37
-24
@@ -1,21 +1,21 @@
|
|||||||
# Integration tests
|
# Integration tests
|
||||||
|
|
||||||
This allows running integration tests using [Playwright](https://playwright.dev/).
|
This allows running integration tests using [Playwright](https://playwright.dev/).
|
||||||
\
|
|
||||||
It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
|
It uses its own `test.env` with different ports to not collide with a running dev instance.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
This relies on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
||||||
Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers.
|
Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers.
|
||||||
|
|
||||||
### Running Playwright outside docker
|
### Running Playwright outside docker
|
||||||
|
|
||||||
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
|
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change.
|
||||||
You'll additionally need `nodejs` then run:
|
You will additionally need `nodejs` then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm ci --ignore-scripts
|
||||||
npx playwright install-deps
|
npx playwright install-deps
|
||||||
npx playwright install firefox
|
npx playwright install firefox
|
||||||
```
|
```
|
||||||
@@ -33,7 +33,7 @@ To force a rebuild of the Playwright image:
|
|||||||
DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright
|
DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
To access the ui to easily run test individually and debug if needed (will not work in docker):
|
To access the UI to easily run test individually and debug if needed (this will not work in docker):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright test --ui
|
npx playwright test --ui
|
||||||
@@ -42,7 +42,7 @@ npx playwright test --ui
|
|||||||
### DB
|
### DB
|
||||||
|
|
||||||
Projects are configured to allow to run tests only on specific database.
|
Projects are configured to allow to run tests only on specific database.
|
||||||
\
|
|
||||||
You can use:
|
You can use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
|
|
||||||
### Keep services running
|
### Keep services running
|
||||||
|
|
||||||
If you want you can keep the Db and Keycloak runnning (states are not impacted by the tests):
|
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
|
||||||
@@ -86,31 +86,43 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
|
|||||||
|
|
||||||
## Writing scenario
|
## Writing scenario
|
||||||
|
|
||||||
When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden ids).
|
When creating new scenario use the recorder to more easily identify elements
|
||||||
|
(in general try to rely on visible hint to identify elements and not hidden IDs).
|
||||||
This does not start the server, you will need to start it manually.
|
This does not start the server, you will need to start it manually.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright codegen "http://127.0.0.1:8000"
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
|
npx playwright codegen "http://127.0.0.1:8003"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Override web-vault
|
## Override web-vault
|
||||||
|
|
||||||
It's possible to change the `web-vault` used by referencing a different `bw_web_builds` commit.
|
It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit.
|
||||||
|
|
||||||
|
Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`.
|
||||||
|
Ensure that the image is built with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PW_WV_REPO_URL=https://github.com/Timshel/oidc_web_builds.git
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Vaultwarden
|
||||||
export PW_WV_COMMIT_HASH=8707dc76df3f0cceef2be5bfae37bb29bd17fae6
|
|
||||||
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Playwright
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can check the result running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`.
|
||||||
|
|
||||||
# OpenID Connect test setup
|
# OpenID Connect test setup
|
||||||
|
|
||||||
Additionally this `docker-compose` template allow to run locally `VaultWarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
|
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.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/).
|
||||||
First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`).
|
First create a copy of `.env.template` as `.env` (This is done to prevent committing your custom settings, Ex `SMTP_`).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -125,11 +137,12 @@ keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
|||||||
oidc_keycloakSetup_1 exited with code 0
|
oidc_keycloakSetup_1 exited with code 0
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
|
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).
|
||||||
|
|
||||||
Then you can access :
|
Then you can access :
|
||||||
|
|
||||||
- `VaultWarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
|
||||||
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
|
||||||
- `Maildev` on http://0.0.0.0:1080
|
- `Maildev` on http://0.0.0.0:1080
|
||||||
|
|
||||||
@@ -143,7 +156,7 @@ You can run just `Keycloak` with `--profile keycloak`:
|
|||||||
```bash
|
```bash
|
||||||
> docker compose --profile keycloak --env-file .env up
|
> docker compose --profile keycloak --env-file .env up
|
||||||
```
|
```
|
||||||
When running with a local VaultWarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases).
|
When running with a local Vaultwarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases).
|
||||||
|
|
||||||
## Rebuilding the Vaultwarden
|
## Rebuilding the Vaultwarden
|
||||||
|
|
||||||
@@ -155,12 +168,12 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
||||||
The content of the file will be loaded as environment variables in all containers.
|
The content of the file will be loaded as environment variables in all containers.
|
||||||
|
|
||||||
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `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) 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)).
|
||||||
- All `VaultWarden` configuration can be set (EX: `SMTP_*`)
|
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
|
||||||
Use `docker compose --profile vaultWarden down`.
|
Use `docker compose --profile vaultwarden down`.
|
||||||
|
|||||||
@@ -1,40 +1,19 @@
|
|||||||
FROM docker.io/library/debian:bookworm-slim as build
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ARG KEYCLOAK_VERSION
|
ARG KEYCLOAK_VERSION
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update && apt-get install -y ca-certificates curl jq openjdk-21-jdk-headless wget
|
||||||
&& apt-get install -y ca-certificates curl wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz
|
RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz \
|
||||||
|
&& mkdir -p /opt/keycloak \
|
||||||
FROM docker.io/library/debian:bookworm-slim
|
&& mv /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin \
|
||||||
|
&& rm -rf /keycloak-${KEYCLOAK_VERSION}
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ARG KEYCLOAK_VERSION
|
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y ca-certificates curl wget \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
ARG JAVA_URL
|
|
||||||
ARG JAVA_VERSION
|
|
||||||
|
|
||||||
ENV JAVA_VERSION=${JAVA_VERSION}
|
|
||||||
|
|
||||||
RUN mkdir -p /opt/openjdk && cd /opt/openjdk \
|
|
||||||
&& wget -c "${JAVA_URL}" -O - | tar -xz
|
|
||||||
|
|
||||||
WORKDIR /
|
|
||||||
|
|
||||||
COPY setup.sh /setup.sh
|
COPY setup.sh /setup.sh
|
||||||
COPY --from=build /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin
|
|
||||||
|
|
||||||
CMD "/setup.sh"
|
CMD "/setup.sh"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
export PATH=/opt/keycloak/bin:/opt/openjdk/jdk-${JAVA_VERSION}/bin:$PATH
|
export PATH=/opt/keycloak/bin:$PATH
|
||||||
export JAVA_HOME=/opt/openjdk/jdk-${JAVA_VERSION}
|
|
||||||
|
|
||||||
STATUS_CODE=0
|
STATUS_CODE=0
|
||||||
while [[ "$STATUS_CODE" != "404" ]] ; do
|
while [[ "$STATUS_CODE" != "404" ]] ; do
|
||||||
@@ -34,3 +33,12 @@ kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=pas
|
|||||||
|
|
||||||
# Dummy realm to mark end of setup
|
# Dummy realm to mark end of setup
|
||||||
kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
||||||
|
|
||||||
|
# TO DEBUG uncomment the following line to keep the setup container running
|
||||||
|
# sleep 3600
|
||||||
|
# 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
|
||||||
|
# ENJOY
|
||||||
|
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM docker.io/library/debian:bookworm-slim
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y ca-certificates curl \
|
&& apt-get install -y ca-certificates curl \
|
||||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||||
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list \
|
&& echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" | tee /etc/apt/sources.list.d/docker.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
containerd.io \
|
containerd.io \
|
||||||
@@ -27,8 +27,8 @@ RUN apt-get update \
|
|||||||
RUN mkdir /playwright
|
RUN mkdir /playwright
|
||||||
WORKDIR /playwright
|
WORKDIR /playwright
|
||||||
|
|
||||||
COPY package.json .
|
COPY package.json package-lock.json .
|
||||||
RUN npm install && npx playwright install-deps && npx playwright install firefox
|
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox
|
||||||
|
|
||||||
COPY docker-compose.yml test.env ./
|
COPY docker-compose.yml test.env ./
|
||||||
COPY compose ./compose
|
COPY compose ./compose
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt
|
FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt
|
||||||
|
|
||||||
FROM node:18-bookworm AS build
|
FROM node:22-trixie AS build
|
||||||
|
|
||||||
ARG REPO_URL
|
ARG REPO_URL
|
||||||
ARG COMMIT_HASH
|
ARG COMMIT_HASH
|
||||||
@@ -14,7 +14,7 @@ COPY build.sh /build.sh
|
|||||||
RUN /build.sh
|
RUN /build.sh
|
||||||
|
|
||||||
######################## RUNTIME IMAGE ########################
|
######################## RUNTIME IMAGE ########################
|
||||||
FROM docker.io/library/debian:bookworm-slim
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ RUN mkdir /data && \
|
|||||||
--no-install-recommends \
|
--no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
libmariadb-dev-compat \
|
libmariadb-dev \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
openssl && \
|
openssl && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ echo $COMMIT_HASH
|
|||||||
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
|
||||||
rm -rf /web-vault
|
rm -rf /web-vault
|
||||||
|
|
||||||
mkdir bw_web_builds;
|
mkdir -p vw_web_builds;
|
||||||
cd bw_web_builds;
|
cd vw_web_builds;
|
||||||
|
|
||||||
git -c init.defaultBranch=main init
|
git -c init.defaultBranch=main init
|
||||||
git remote add origin "$REPO_URL"
|
git remote add origin "$REPO_URL"
|
||||||
git fetch --depth 1 origin "$COMMIT_HASH"
|
git fetch --depth 1 origin "$COMMIT_HASH"
|
||||||
git -c advice.detachedHead=false checkout FETCH_HEAD
|
git -c advice.detachedHead=false checkout FETCH_HEAD
|
||||||
|
|
||||||
export VAULT_VERSION=$(cat Dockerfile | grep "ARG VAULT_VERSION" | cut -d "=" -f2)
|
npm ci --ignore-scripts
|
||||||
./scripts/checkout_web_vault.sh
|
|
||||||
./scripts/patch_web_vault.sh
|
|
||||||
./scripts/build_web_vault.sh
|
|
||||||
printf '{"version":"%s"}' "$COMMIT_HASH" > ./web-vault/apps/web/build/vw-version.json
|
|
||||||
|
|
||||||
mv ./web-vault/apps/web/build /web-vault
|
cd apps/web
|
||||||
|
npm run dist:oss:selfhost
|
||||||
|
printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json
|
||||||
|
|
||||||
|
mv build /web-vault
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ services:
|
|||||||
context: compose/warden
|
context: compose/warden
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
REPO_URL: ${PW_WV_REPO_URL:-}
|
REPO_URL: ${PW_VW_REPO_URL:-}
|
||||||
COMMIT_HASH: ${PW_WV_COMMIT_HASH:-}
|
COMMIT_HASH: ${PW_VW_COMMIT_HASH:-}
|
||||||
env_file: ${DC_ENV_FILE:-.env}
|
env_file: ${DC_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
|
- ADMIN_TOKEN
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
- I_REALLY_WANT_VOLATILE_STORAGE
|
- I_REALLY_WANT_VOLATILE_STORAGE
|
||||||
- LOG_LEVEL
|
- LOG_LEVEL
|
||||||
@@ -30,9 +31,10 @@ services:
|
|||||||
- SMTP_FROM
|
- SMTP_FROM
|
||||||
- SMTP_DEBUG
|
- SMTP_DEBUG
|
||||||
- SSO_DEBUG_TOKENS
|
- SSO_DEBUG_TOKENS
|
||||||
- SSO_FRONTEND
|
|
||||||
- SSO_ENABLED
|
- SSO_ENABLED
|
||||||
|
- SSO_FRONTEND
|
||||||
- SSO_ONLY
|
- SSO_ONLY
|
||||||
|
- SSO_SCOPES
|
||||||
restart: "no"
|
restart: "no"
|
||||||
depends_on:
|
depends_on:
|
||||||
- VaultwardenPrebuild
|
- VaultwardenPrebuild
|
||||||
@@ -100,7 +102,7 @@ services:
|
|||||||
Keycloak:
|
Keycloak:
|
||||||
profiles: ["keycloak", "vaultwarden"]
|
profiles: ["keycloak", "vaultwarden"]
|
||||||
container_name: keycloak-${ENV:-dev}
|
container_name: keycloak-${ENV:-dev}
|
||||||
image: quay.io/keycloak/keycloak:25.0.4
|
image: quay.io/keycloak/keycloak:26.3.4
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
@@ -114,9 +116,7 @@ services:
|
|||||||
context: compose/keycloak
|
context: compose/keycloak
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
KEYCLOAK_VERSION: 25.0.4
|
KEYCLOAK_VERSION: 26.3.4
|
||||||
JAVA_URL: https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz
|
|
||||||
JAVA_VERSION: 21.0.2
|
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
depends_on:
|
depends_on:
|
||||||
- Keycloak
|
- Keycloak
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const fs = require("fs");
|
|||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
|
|
||||||
export function loadEnv(){
|
export function loadEnv(){
|
||||||
var myEnv = dotenv.config({ path: 'test.env' });
|
var myEnv = dotenv.config({ path: 'test.env', quiet: true });
|
||||||
dotenvExpand.expand(myEnv);
|
dotenvExpand.expand(myEnv);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -221,9 +221,13 @@ export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkNotification(page: Page, hasText: string) {
|
export async function checkNotification(page: Page, hasText: string) {
|
||||||
await expect(page.locator('bit-toast').filter({ hasText })).toBeVisible();
|
await expect(page.locator('bit-toast', { hasText })).toBeVisible();
|
||||||
await page.locator('bit-toast').filter({ hasText }).getByRole('button').click();
|
try {
|
||||||
await expect(page.locator('bit-toast').filter({ hasText })).toHaveCount(0);
|
await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Closing notification failed but it should now be invisible (${error})`);
|
||||||
|
}
|
||||||
|
await expect(page.locator('bit-toast', { hasText })).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanLanding(page: Page) {
|
export async function cleanLanding(page: Page) {
|
||||||
@@ -244,3 +248,15 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ignoreExtension(page: Page) {
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
|
||||||
|
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Extension setup not visible. Continuing');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
Generated
+713
-554
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,14 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.53.0",
|
"@playwright/test": "1.56.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "17.2.3",
|
||||||
"dotenv-expand": "^12.0.2",
|
"dotenv-expand": "12.0.3",
|
||||||
"maildev": "npm:@timshel_npm/maildev@^3.1.2"
|
"maildev": "npm:@timshel_npm/maildev@3.2.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "3.15.3",
|
||||||
"otpauth": "^9.4.0",
|
"otpauth": "9.4.1",
|
||||||
"pg": "^8.16.0"
|
"pg": "8.16.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export default defineConfig({
|
|||||||
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
|
||||||
*/
|
*/
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
actionTimeout: 10 * 1000,
|
actionTimeout: 20 * 1000,
|
||||||
navigationTimeout: 10 * 1000,
|
navigationTimeout: 20 * 1000,
|
||||||
expect: { timeout: 10 * 1000 },
|
expect: { timeout: 20 * 1000 },
|
||||||
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
+7
-2
@@ -43,7 +43,7 @@ KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
|||||||
KC_HTTP_HOST=127.0.0.1
|
KC_HTTP_HOST=127.0.0.1
|
||||||
KC_HTTP_PORT=8081
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
# Script parameters (use Keycloak and VaultWarden config too)
|
# Script parameters (use Keycloak and Vaultwarden config too)
|
||||||
TEST_REALM=test
|
TEST_REALM=test
|
||||||
DUMMY_REALM=dummy
|
DUMMY_REALM=dummy
|
||||||
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
||||||
@@ -52,9 +52,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
|
|||||||
# Vaultwarden Config #
|
# Vaultwarden Config #
|
||||||
######################
|
######################
|
||||||
ROCKET_PORT=8003
|
ROCKET_PORT=8003
|
||||||
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
DOMAIN=http://localhost:${ROCKET_PORT}
|
||||||
LOG_LEVEL=info,oidcwarden::sso=debug
|
LOG_LEVEL=info,oidcwarden::sso=debug
|
||||||
LOGIN_RATELIMIT_MAX_BURST=100
|
LOGIN_RATELIMIT_MAX_BURST=100
|
||||||
|
ADMIN_TOKEN=admin
|
||||||
|
|
||||||
SMTP_SECURITY=off
|
SMTP_SECURITY=off
|
||||||
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
SMTP_PORT=${MAILDEV_SMTP_PORT}
|
||||||
@@ -66,6 +67,10 @@ SSO_CLIENT_SECRET=warden
|
|||||||
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
||||||
SSO_DEBUG_TOKENS=true
|
SSO_DEBUG_TOKENS=true
|
||||||
|
|
||||||
|
# Custom web-vault build
|
||||||
|
# PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git
|
||||||
|
# PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
# Docker MariaDb container#
|
# Docker MariaDb container#
|
||||||
###########################
|
###########################
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ test('2fa', async ({ page }) => {
|
|||||||
await page.getByLabel(/Verification code/).fill(code);
|
await page.getByLabel(/Verification code/).fill(code);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add it later' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Skip to web app' }).click();
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Vaults/);
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ test('Invite users', async ({ page }) => {
|
|||||||
await createAccount(test, page, users.user1, mail1Buffer);
|
await createAccount(test, page, users.user1, mail1Buffer);
|
||||||
|
|
||||||
await orgs.create(test, page, 'Test');
|
await orgs.create(test, page, 'Test');
|
||||||
|
|
||||||
|
await test.step(`Set account recovery`, async () => {
|
||||||
|
await orgs.policies(test, page, 'Test');
|
||||||
|
await page.getByRole('button', { name: 'Account recovery' }).click();
|
||||||
|
await page.getByRole('checkbox', { name: 'Turn on' }).check();
|
||||||
|
await page.getByRole('checkbox', { name: 'Require new members' }).check();
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await utils.checkNotification(page, 'Edited policy Account recovery');
|
||||||
|
});
|
||||||
|
|
||||||
await orgs.members(test, page, 'Test');
|
await orgs.members(test, page, 'Test');
|
||||||
await orgs.invite(test, page, 'Test', users.user2.email);
|
await orgs.invite(test, page, 'Test', users.user2.email);
|
||||||
await orgs.invite(test, page, 'Test', users.user3.email, {
|
await orgs.invite(test, page, 'Test', users.user3.email, {
|
||||||
@@ -57,15 +67,17 @@ test('invited with new account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
|
||||||
|
|
||||||
//await page.getByLabel('Name').fill(users.user2.name);
|
//await page.getByLabel('Name').fill(users.user2.name);
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
await utils.checkNotification(page, 'Your new account has been created');
|
await utils.checkNotification(page, 'Your new account has been created');
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// Redirected to the vault
|
// Redirected to the vault
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -91,9 +103,11 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
|
||||||
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
|
||||||
@@ -113,3 +127,27 @@ test('Organization is visible', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
|
||||||
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Recover user password', async ({ page }) => {
|
||||||
|
await logUser(test, page, users.user1, mail1Buffer);
|
||||||
|
|
||||||
|
let newPassword = "TotoNewPassword";
|
||||||
|
|
||||||
|
await orgs.members(test, page, 'Test');
|
||||||
|
await test.step(`Rrcover ${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, 'Password reset success');
|
||||||
|
});
|
||||||
|
|
||||||
|
let user2 = {
|
||||||
|
email: users.user2.email,
|
||||||
|
name: users.user2.name,
|
||||||
|
password: newPassword,
|
||||||
|
};
|
||||||
|
await logUser(test, page, user2, mail2Buffer);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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', 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', exact: true }).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)' }).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
|
|||||||
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
await page.getByRole('menuitem', { name: 'Account settings' }).click();
|
||||||
await page.getByRole('link', { name: 'Security' }).click();
|
await page.getByRole('link', { name: 'Security' }).click();
|
||||||
await page.getByRole('link', { name: 'Two-step login' }).click();
|
await page.getByRole('link', { name: 'Two-step login' }).click();
|
||||||
await page.locator('bit-item').filter({ hasText: 'Email Email Enter a code sent' }).getByRole('button').click();
|
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
|
||||||
await page.getByLabel('Master password (required)').fill(user.password);
|
await page.getByLabel('Master password (required)').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page.getByRole('button', { name: 'Send email' }).click();
|
await page.getByRole('button', { name: 'Send email' }).click();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function logNewUser(
|
|||||||
test: Test,
|
test: Test,
|
||||||
page: Page,
|
page: Page,
|
||||||
user: { email: string, name: string, password: string },
|
user: { email: string, name: string, password: string },
|
||||||
options: { mailBuffer?: MailBuffer, override?: boolean } = {}
|
options: { mailBuffer?: MailBuffer } = {}
|
||||||
) {
|
) {
|
||||||
await test.step(`Create user ${user.name}`, async () => {
|
await test.step(`Create user ${user.name}`, async () => {
|
||||||
await page.context().clearCookies();
|
await page.context().clearCookies();
|
||||||
@@ -20,12 +20,8 @@ export async function logNewUser(
|
|||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
if( options.override ) {
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
} else {
|
|
||||||
await page.getByLabel(/Email address/).fill(user.email);
|
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Keycloak login', async () => {
|
await test.step('Keycloak login', async () => {
|
||||||
@@ -37,19 +33,21 @@ export async function logNewUser(
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
|
|
||||||
if( options.mailBuffer ){
|
if( options.mailBuffer ){
|
||||||
let mailBuffer = options.mailBuffer;
|
let mailBuffer = options.mailBuffer;
|
||||||
await test.step('Check emails', async () => {
|
await test.step('Check emails', async () => {
|
||||||
@@ -69,7 +67,6 @@ export async function logUser(
|
|||||||
user: { email: string, password: string },
|
user: { email: string, password: string },
|
||||||
options: {
|
options: {
|
||||||
mailBuffer ?: MailBuffer,
|
mailBuffer ?: MailBuffer,
|
||||||
override?: boolean,
|
|
||||||
totp?: OTPAuth.TOTP,
|
totp?: OTPAuth.TOTP,
|
||||||
mail2fa?: boolean,
|
mail2fa?: boolean,
|
||||||
} = {}
|
} = {}
|
||||||
@@ -82,12 +79,8 @@ export async function logUser(
|
|||||||
await test.step('Landing page', async () => {
|
await test.step('Landing page', async () => {
|
||||||
await utils.cleanLanding(page);
|
await utils.cleanLanding(page);
|
||||||
|
|
||||||
if( options.override ) {
|
await page.locator("input[type=email].vw-email-sso").fill(user.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
} else {
|
|
||||||
await page.getByLabel(/Email address/).fill(user.email);
|
|
||||||
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
await page.getByRole('button', { name: /Use single sign-on/ }).click();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Keycloak login', async () => {
|
await test.step('Keycloak login', async () => {
|
||||||
@@ -124,6 +117,8 @@ export async function logUser(
|
|||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
|
||||||
|
|||||||
@@ -17,15 +17,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
|
|||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
// Vault finish Creation
|
// Vault finish Creation
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(user.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(user.password);
|
await page.getByLabel('Confirm master password (').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Your new account has been created')
|
await utils.checkNotification(page, 'Your new account has been created')
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
|
||||||
await utils.checkNotification(page, 'You have been logged in!');
|
// await utils.checkNotification(page, 'You have been logged in!');
|
||||||
|
|
||||||
if( mailBuffer ){
|
if( mailBuffer ){
|
||||||
await mailBuffer.expect((m) => m.subject === "Welcome");
|
await mailBuffer.expect((m) => m.subject === "Welcome");
|
||||||
@@ -45,6 +46,8 @@ export async function logUser(test, page: Page, user: { email: string, password:
|
|||||||
await page.getByLabel('Master password').fill(user.password);
|
await page.getByLabel('Master password').fill(user.password);
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
|
|
||||||
// We are now in the default vault page
|
// We are now in the default vault page
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ test('SSO login', async ({ page }) => {
|
|||||||
test('Non SSO login', async ({ page }) => {
|
test('Non SSO login', async ({ page }) => {
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Other' }).click();
|
||||||
|
|
||||||
// Unlock page
|
// Unlock page
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
await page.getByLabel('Master password').fill(users.user1.password);
|
||||||
@@ -58,20 +58,12 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
|
||||||
|
|
||||||
// Check that SSO login is available
|
// Check that SSO login is available
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
|
||||||
|
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
// No Continue/Other
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
|
||||||
|
|
||||||
// Unlock page
|
|
||||||
await page.getByLabel('Master password').fill(users.user1.password);
|
|
||||||
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
|
||||||
|
|
||||||
// An error should appear
|
|
||||||
await page.getByLabel('SSO sign-in is required')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -82,13 +74,12 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
|
|||||||
|
|
||||||
// Landing page
|
// Landing page
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByLabel(/Email address/).fill(users.user1.email);
|
|
||||||
|
|
||||||
// No SSO button (rely on a correct selector checked in previous test)
|
// No SSO button (rely on a correct selector checked in previous test)
|
||||||
await page.getByLabel('Master password');
|
|
||||||
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
|
||||||
|
|
||||||
// Can continue to Master password
|
// Can continue to Master password
|
||||||
|
await page.getByLabel(/Email address/).fill(users.user1.email);
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
await expect(page.getByRole('button', { name: /Log in with master password/ })).toHaveCount(1);
|
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,16 +67,17 @@ test('invited with new account', async ({ page }) => {
|
|||||||
|
|
||||||
await test.step('Create Vault account', async () => {
|
await test.step('Create Vault account', async () => {
|
||||||
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
|
||||||
await page.getByLabel('New master password (required)', { exact: true }).fill(users.user2.password);
|
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
|
||||||
await page.getByLabel('Confirm new master password (').fill(users.user2.password);
|
await page.getByLabel('Confirm master password (').fill(users.user2.password);
|
||||||
await page.getByRole('button', { name: 'Create account' }).click();
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Account successfully created!');
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
|
|
||||||
await utils.checkNotification(page, 'Account successfully created!');
|
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
@@ -107,11 +108,13 @@ test('invited with existing account', async ({ page }) => {
|
|||||||
await expect(page).toHaveTitle('Vaultwarden Web');
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
await page.getByLabel('Master password').fill(users.user3.password);
|
await page.getByLabel('Master password').fill(users.user3.password);
|
||||||
await page.getByRole('button', { name: 'Unlock' }).click();
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
|
await utils.checkNotification(page, 'Invitation accepted');
|
||||||
|
await utils.ignoreExtension(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Default vault page', async () => {
|
await test.step('Default vault page', async () => {
|
||||||
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
await expect(page).toHaveTitle(/Vaultwarden Web/);
|
||||||
await utils.checkNotification(page, 'Invitation accepted');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Check mails', async () => {
|
await test.step('Check mails', async () => {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test('Enforce password policy', async ({ page }) => {
|
|||||||
await utils.logout(test, page, users.user1);
|
await utils.logout(test, page, users.user1);
|
||||||
|
|
||||||
await test.step(`Unlock trigger policy`, async () => {
|
await test.step(`Unlock trigger policy`, async () => {
|
||||||
await page.getByRole('textbox', { name: 'Email address (required)' }).fill(users.user1.email);
|
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('button', { name: 'Use single sign-on' }).click();
|
||||||
|
|
||||||
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.88.0"
|
channel = "1.96.1"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
edition = "2021"
|
edition = "2024"
|
||||||
max_width = 120
|
max_width = 120
|
||||||
newline_style = "Unix"
|
newline_style = "Unix"
|
||||||
use_small_heuristics = "Off"
|
use_small_heuristics = "Off"
|
||||||
|
|||||||
+234
-191
@@ -1,34 +1,41 @@
|
|||||||
use once_cell::sync::Lazy;
|
use std::{env, sync::LazyLock};
|
||||||
use reqwest::Method;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
use rocket::serde::json::Json;
|
use reqwest::Method;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
form::Form,
|
form::Form,
|
||||||
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
http::{Cookie, CookieJar, MediaType, SameSite, Status},
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
response::{content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawHtml as Html},
|
||||||
Catcher, Route,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, VERSION,
|
||||||
api::{
|
api::{
|
||||||
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType},
|
db::{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
mail,
|
mail,
|
||||||
|
sso::FAKE_SSO_IDENTIFIER,
|
||||||
util::{
|
util::{
|
||||||
container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version,
|
FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
|
||||||
is_running_in_container, NumberOrString,
|
get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
|
||||||
},
|
},
|
||||||
CONFIG, VERSION,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -75,18 +82,20 @@ pub fn catchers() -> Vec<Catcher> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static DB_TYPE: Lazy<&str> = Lazy::new(|| {
|
static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
|
||||||
DbConnType::from_url(&CONFIG.database_url())
|
#[cfg(mysql)]
|
||||||
.map(|t| match t {
|
Some(DbConnType::Mysql) => "MySQL",
|
||||||
DbConnType::sqlite => "SQLite",
|
#[cfg(postgresql)]
|
||||||
DbConnType::mysql => "MySQL",
|
Some(DbConnType::Postgresql) => "PostgreSQL",
|
||||||
DbConnType::postgresql => "PostgreSQL",
|
#[cfg(sqlite)]
|
||||||
})
|
Some(DbConnType::Sqlite) => "SQLite",
|
||||||
.unwrap_or("Unknown")
|
_ => "Unknown",
|
||||||
});
|
});
|
||||||
|
|
||||||
static CAN_BACKUP: Lazy<bool> =
|
#[cfg(sqlite)]
|
||||||
Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).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);
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
fn admin_disabled() -> &'static str {
|
fn admin_disabled() -> &'static str {
|
||||||
@@ -148,10 +157,10 @@ fn admin_login(request: &Request<'_>) -> ApiResult<Html<String>> {
|
|||||||
err_code!("Authorization failed.", Status::Unauthorized.code);
|
err_code!("Authorization failed.", Status::Unauthorized.code);
|
||||||
}
|
}
|
||||||
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||||
render_admin_login(None, Some(redirect))
|
render_admin_login(None, Some(&redirect))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> ApiResult<Html<String>> {
|
fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Html<String>> {
|
||||||
// If there is an error, show it
|
// If there is an error, show it
|
||||||
let msg = msg.map(|msg| format!("Error: {msg}"));
|
let msg = msg.map(|msg| format!("Error: {msg}"));
|
||||||
let json = json!({
|
let json = json!({
|
||||||
@@ -185,15 +194,12 @@ fn post_admin_login(
|
|||||||
if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
|
if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
|
||||||
return Err(AdminResponse::TooManyRequests(render_admin_login(
|
return Err(AdminResponse::TooManyRequests(render_admin_login(
|
||||||
Some("Too many requests, try again later."),
|
Some("Too many requests, try again later."),
|
||||||
redirect,
|
redirect.as_deref(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if !_validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
error!("Invalid admin token. IP: {}", ip.ip);
|
|
||||||
Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect)))
|
|
||||||
} else {
|
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = generate_admin_claims();
|
let claims = generate_admin_claims();
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
@@ -211,10 +217,16 @@ fn post_admin_login(
|
|||||||
} else {
|
} else {
|
||||||
Err(AdminResponse::Ok(render_admin_page()))
|
Err(AdminResponse::Ok(render_admin_page()))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Invalid admin token. IP: {}", ip.ip);
|
||||||
|
Err(AdminResponse::Unauthorized(render_admin_login(
|
||||||
|
Some("Invalid admin token, please try again."),
|
||||||
|
redirect.as_deref(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _validate_token(token: &str) -> bool {
|
fn validate_token(token: &str) -> bool {
|
||||||
match CONFIG.admin_token().as_ref() {
|
match CONFIG.admin_token().as_ref() {
|
||||||
None => false,
|
None => false,
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
@@ -284,7 +296,7 @@ struct InviteData {
|
|||||||
email: String,
|
email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user_or_404(user_id: &UserId, conn: &mut DbConn) -> ApiResult<User> {
|
async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
|
||||||
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
||||||
Ok(user)
|
Ok(user)
|
||||||
} else {
|
} else {
|
||||||
@@ -293,18 +305,15 @@ async fn get_user_or_404(user_id: &UserId, conn: &mut DbConn) -> ApiResult<User>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/invite", format = "application/json", data = "<data>")]
|
#[post("/invite", format = "application/json", data = "<data>")]
|
||||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||||
let data: InviteData = data.into_inner();
|
async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
|
||||||
if User::find_by_mail(&data.email, &mut 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: &mut DbConn) -> EmptyResult {
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
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
|
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
@@ -312,10 +321,17 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_generate_invite(&user, &mut conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
let data: InviteData = data.into_inner();
|
||||||
user.save(&mut conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
|
if User::find_by_mail(&data.email, &conn).await.is_some() {
|
||||||
|
err_code!("User already exists", Status::Conflict.code)
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(user.to_json(&mut conn).await))
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/test/smtp", format = "application/json", data = "<data>")]
|
#[post("/test/smtp", format = "application/json", data = "<data>")]
|
||||||
@@ -336,14 +352,14 @@ fn logout(cookies: &CookieJar<'_>) -> Redirect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/users")]
|
#[get("/users")]
|
||||||
async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
|
async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json<Value> {
|
||||||
let users = User::get_all(&mut conn).await;
|
let users = User::get_all(&conn).await;
|
||||||
let mut users_json = Vec::with_capacity(users.len());
|
let mut users_json = Vec::with_capacity(users.len());
|
||||||
for (u, _) in users {
|
for (u, _) in users {
|
||||||
let mut usr = u.to_json(&mut conn).await;
|
let mut usr = u.to_json(&conn).await;
|
||||||
usr["userEnabled"] = json!(u.enabled);
|
usr["userEnabled"] = json!(u.enabled);
|
||||||
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||||
usr["lastActive"] = match u.last_active(&mut conn).await {
|
usr["lastActive"] = match u.last_active(&conn).await {
|
||||||
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
|
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
|
||||||
None => json!(None::<String>),
|
None => json!(None::<String>),
|
||||||
};
|
};
|
||||||
@@ -354,22 +370,22 @@ async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/users/overview")]
|
#[get("/users/overview")]
|
||||||
async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<String>> {
|
async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
let users = User::get_all(&mut conn).await;
|
let users = User::get_all(&conn).await;
|
||||||
let mut users_json = Vec::with_capacity(users.len());
|
let mut users_json = Vec::with_capacity(users.len());
|
||||||
for (u, sso_u) in users {
|
for (u, sso_u) in users {
|
||||||
let mut usr = u.to_json(&mut conn).await;
|
let mut usr = u.to_json(&conn).await;
|
||||||
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &mut conn).await);
|
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await);
|
||||||
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &mut conn).await);
|
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await);
|
||||||
usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &mut conn).await));
|
usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn).await));
|
||||||
usr["user_enabled"] = json!(u.enabled);
|
usr["user_enabled"] = json!(u.enabled);
|
||||||
usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||||
usr["last_active"] = match u.last_active(&mut conn).await {
|
usr["last_active"] = match u.last_active(&conn).await {
|
||||||
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
|
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
|
||||||
None => json!("Never"),
|
None => json!("Never"),
|
||||||
};
|
};
|
||||||
|
|
||||||
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
|
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
|
||||||
|
|
||||||
users_json.push(usr);
|
users_json.push(usr);
|
||||||
}
|
}
|
||||||
@@ -379,9 +395,9 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/users/by-mail/<mail>")]
|
#[get("/users/by-mail/<mail>")]
|
||||||
async fn get_user_by_mail_json(mail: &str, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||||
if let Some(u) = User::find_by_mail(mail, &mut conn).await {
|
if let Some(u) = User::find_by_mail(mail, &conn).await {
|
||||||
let mut usr = u.to_json(&mut conn).await;
|
let mut usr = u.to_json(&conn).await;
|
||||||
usr["userEnabled"] = json!(u.enabled);
|
usr["userEnabled"] = json!(u.enabled);
|
||||||
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||||
Ok(Json(usr))
|
Ok(Json(usr))
|
||||||
@@ -391,21 +407,21 @@ async fn get_user_by_mail_json(mail: &str, _token: AdminToken, mut conn: DbConn)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/users/<user_id>")]
|
#[get("/users/<user_id>")]
|
||||||
async fn get_user_json(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> JsonResult {
|
||||||
let u = get_user_or_404(&user_id, &mut conn).await?;
|
let u = get_user_or_404(&user_id, &conn).await?;
|
||||||
let mut usr = u.to_json(&mut conn).await;
|
let mut usr = u.to_json(&conn).await;
|
||||||
usr["userEnabled"] = json!(u.enabled);
|
usr["userEnabled"] = json!(u.enabled);
|
||||||
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||||
Ok(Json(usr))
|
Ok(Json(usr))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||||
async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let user = get_user_or_404(&user_id, &mut conn).await?;
|
let user = get_user_or_404(&user_id, &conn).await?;
|
||||||
|
|
||||||
// Get the membership records before deleting the actual user
|
// Get the membership records before deleting the actual user
|
||||||
let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await;
|
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||||
let res = user.delete(&mut conn).await;
|
let res = user.delete(&conn).await;
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
@@ -415,7 +431,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
|
|||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
14, // Use UnknownBrowser type
|
14, // Use UnknownBrowser type
|
||||||
&token.ip.ip,
|
&token.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -424,9 +440,9 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/users/<user_id>/sso", format = "application/json")]
|
#[delete("/users/<user_id>/sso", format = "application/json")]
|
||||||
async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await;
|
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||||
let res = SsoUser::delete(&user_id, &mut conn).await;
|
let res = SsoUser::delete(&user_id, &conn).await;
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
@@ -436,7 +452,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -
|
|||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
14, // Use UnknownBrowser type
|
14, // Use UnknownBrowser type
|
||||||
&token.ip.ip,
|
&token.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -445,68 +461,73 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/deauth", format = "application/json")]
|
#[post("/users/<user_id>/deauth", format = "application/json")]
|
||||||
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||||
|
|
||||||
nt.send_logout(&user, None, &mut conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
for device in Device::find_push_devices_by_user(&user.uuid, &mut conn).await {
|
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,
|
Ok(r) => r,
|
||||||
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Device::delete_all_by_user(&user.uuid, &mut conn).await?;
|
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
user.reset_security_stamp();
|
user.reset_security_stamp(&conn).await?;
|
||||||
|
|
||||||
user.save(&mut conn).await
|
user.save(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||||
async fn disable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||||
Device::delete_all_by_user(&user.uuid, &mut conn).await?;
|
user.reset_security_stamp(&conn).await?;
|
||||||
user.reset_security_stamp();
|
|
||||||
user.enabled = false;
|
user.enabled = false;
|
||||||
|
|
||||||
let save_result = user.save(&mut conn).await;
|
let save_result = user.save(&conn).await;
|
||||||
|
|
||||||
nt.send_logout(&user, None, &mut conn).await;
|
nt.send_logout(&user, None, &conn).await;
|
||||||
|
|
||||||
|
Device::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
|
|
||||||
save_result
|
save_result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/enable", format = "application/json")]
|
#[post("/users/<user_id>/enable", format = "application/json")]
|
||||||
async fn enable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||||
user.enabled = true;
|
user.enabled = true;
|
||||||
|
|
||||||
user.save(&mut conn).await
|
user.save(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
|
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
|
||||||
async fn remove_2fa(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||||
TwoFactor::delete_all_by_user(&user.uuid, &mut conn).await?;
|
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||||
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &mut conn).await?;
|
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
|
||||||
user.totp_recover = None;
|
user.totp_recover = None;
|
||||||
user.save(&mut conn).await
|
user.save(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/invite/resend", format = "application/json")]
|
#[post("/users/<user_id>/invite/resend", format = "application/json")]
|
||||||
async fn resend_user_invite(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
if let Some(user) = User::find_by_uuid(&user_id, &mut conn).await {
|
if let Some(user) = User::find_by_uuid(&user_id, &conn).await {
|
||||||
//TODO: replace this with user.status check when it will be available (PR#3397)
|
//TODO: replace this with user.status check when it will be available (PR#3397)
|
||||||
if !user.password_hash.is_empty() {
|
if !user.password_hash.is_empty() {
|
||||||
err_code!("User already accepted invitation", Status::BadRequest.code);
|
err_code!("User already accepted invitation", Status::BadRequest.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
let org_id: OrganizationId = if CONFIG.sso_enabled() {
|
||||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
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
|
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -524,43 +545,30 @@ struct MembershipTypeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/org_type", format = "application/json", data = "<data>")]
|
#[post("/users/org_type", format = "application/json", data = "<data>")]
|
||||||
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let data: MembershipTypeData = data.into_inner();
|
let data: MembershipTypeData = data.into_inner();
|
||||||
|
|
||||||
let Some(mut member_to_edit) = Membership::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &mut conn).await
|
let Some(mut member_to_edit) = Membership::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("The specified user isn't member of the organization")
|
err!("The specified user isn't member of the organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid type"),
|
} else {
|
||||||
|
err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||||
// Removing owner permission, check that there is at least one other confirmed owner
|
// Removing owner permission, check that there is at least one other confirmed owner
|
||||||
if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &mut conn).await <= 1 {
|
if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &conn).await <= 1 {
|
||||||
err!("Can't change the type of the last owner")
|
err!("Can't change the type of the last owner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member_to_edit.atype = new_type;
|
||||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||||
// It returns different error messages per function.
|
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
|
||||||
if new_type < MembershipType::Admin {
|
|
||||||
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &member_to_edit.org_uuid, true, &mut conn).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
|
||||||
if CONFIG.email_2fa_auto_fallback() {
|
|
||||||
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &mut conn).await?;
|
|
||||||
} else {
|
|
||||||
err!("You cannot modify this user to this type because they have not setup 2FA");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
|
||||||
err!("You cannot modify this user to this type because it is a member of an organization which forbids it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserUpdated as i32,
|
EventType::OrganizationUserUpdated as i32,
|
||||||
@@ -569,32 +577,31 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
|
|||||||
&ACTING_ADMIN_USER.into(),
|
&ACTING_ADMIN_USER.into(),
|
||||||
14, // Use UnknownBrowser type
|
14, // Use UnknownBrowser type
|
||||||
&token.ip.ip,
|
&token.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
member_to_edit.atype = new_type;
|
member_to_edit.save(&conn).await
|
||||||
member_to_edit.save(&mut conn).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/update_revision", format = "application/json")]
|
#[post("/users/update_revision", format = "application/json")]
|
||||||
async fn update_revision_users(_token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
User::update_all_revisions(&mut conn).await
|
User::update_all_revisions(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/organizations/overview")]
|
#[get("/organizations/overview")]
|
||||||
async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<String>> {
|
async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
let organizations = Organization::get_all(&mut conn).await;
|
let organizations = Organization::get_all(&conn).await;
|
||||||
let mut organizations_json = Vec::with_capacity(organizations.len());
|
let mut organizations_json = Vec::with_capacity(organizations.len());
|
||||||
for o in organizations {
|
for o in organizations {
|
||||||
let mut org = o.to_json();
|
let mut org = o.to_json();
|
||||||
org["user_count"] = json!(Membership::count_by_org(&o.uuid, &mut conn).await);
|
org["user_count"] = json!(Membership::count_by_org(&o.uuid, &conn).await);
|
||||||
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &mut conn).await);
|
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn).await);
|
||||||
org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &mut conn).await);
|
org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &conn).await);
|
||||||
org["group_count"] = json!(Group::count_by_org(&o.uuid, &mut conn).await);
|
org["group_count"] = json!(Group::count_by_org(&o.uuid, &conn).await);
|
||||||
org["event_count"] = json!(Event::count_by_org(&o.uuid, &mut conn).await);
|
org["event_count"] = json!(Event::count_by_org(&o.uuid, &conn).await);
|
||||||
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &mut conn).await);
|
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn).await);
|
||||||
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &mut conn).await));
|
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn).await));
|
||||||
organizations_json.push(org);
|
organizations_json.push(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,9 +610,9 @@ async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/organizations/<org_id>/delete", format = "application/json")]
|
#[post("/organizations/<org_id>/delete", format = "application/json")]
|
||||||
async fn delete_organization(org_id: OrganizationId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let org = Organization::find_by_uuid(&org_id, &mut conn).await.map_res("Organization doesn't exist")?;
|
let org = Organization::find_by_uuid(&org_id, &conn).await.map_res("Organization doesn't exist")?;
|
||||||
org.delete(&mut conn).await
|
org.delete(&conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -636,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
|
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
|
||||||
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
|
||||||
/// Any cache will be lost if Vaultwarden is restarted
|
/// Any cache will be lost if Vaultwarden is restarted
|
||||||
use std::time::Duration; // Needed for cached
|
#[cached(ttl = 600, sync_writes = "default")]
|
||||||
#[cached(time = 600, sync_writes = "default")]
|
|
||||||
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
||||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||||
if has_http_access {
|
if has_http_access {
|
||||||
@@ -650,35 +656,35 @@ async fn get_release_info(has_http_access: bool) -> (String, String, String) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name,
|
Ok(r) => r.tag_name,
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
|
||||||
Ok(mut c) => {
|
Ok(mut c) => {
|
||||||
c.sha.truncate(8);
|
c.sha.truncate(8);
|
||||||
c.sha
|
c.sha
|
||||||
}
|
}
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
// Do not fetch the web-vault version when running within a container
|
// Do not fetch the web-vault version when running within a container
|
||||||
// The web-vault version is embedded within the container it self, and should not be updated manually
|
// The web-vault version is embedded within the container it self, and should not be updated manually
|
||||||
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
|
Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
|
||||||
_ => "-".to_string(),
|
_ => "-".to_owned(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
("-".to_string(), "-".to_string(), "-".to_string())
|
("-".to_owned(), "-".to_owned(), "-".to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_ntp_time(has_http_access: bool) -> String {
|
async fn get_ntp_time(has_http_access: bool) -> String {
|
||||||
if has_http_access {
|
if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
||||||
if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
|
|
||||||
for line in cf_trace.lines() {
|
for line in cf_trace.lines() {
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
if let Some((key, value)) = line.split_once('=')
|
||||||
if key == "ts" {
|
&& key == "ts"
|
||||||
|
{
|
||||||
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
let ts = value.split_once('.').map_or(value, |(s, _)| s);
|
||||||
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
|
||||||
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||||
@@ -687,13 +693,31 @@ async fn get_ntp_time(has_http_access: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
String::from("Unable to fetch NTP time.")
|
String::from("Unable to fetch NTP time.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn web_vault_compare(active: &str, latest: &str) -> i8 {
|
||||||
|
use semver::Version;
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
let active_semver = Version::parse(active).unwrap_or_else(|e| {
|
||||||
|
warn!("Unable to parse active web-vault version '{active}': {e}");
|
||||||
|
Version::parse("2025.1.1").unwrap()
|
||||||
|
});
|
||||||
|
let latest_semver = Version::parse(latest).unwrap_or_else(|e| {
|
||||||
|
warn!("Unable to parse latest web-vault version '{latest}': {e}");
|
||||||
|
Version::parse("2025.1.1").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
match active_semver.cmp(&latest_semver) {
|
||||||
|
Ordering::Less => -1,
|
||||||
|
Ordering::Equal => 0,
|
||||||
|
Ordering::Greater => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/diagnostics")]
|
#[get("/diagnostics")]
|
||||||
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) -> ApiResult<Html<String>> {
|
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use std::net::ToSocketAddrs;
|
use std::net::ToSocketAddrs;
|
||||||
|
|
||||||
@@ -708,35 +732,31 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
|||||||
// Check if we are able to resolve DNS entries
|
// Check if we are able to resolve DNS entries
|
||||||
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
|
||||||
Ok(Some(a)) => a.ip().to_string(),
|
Ok(Some(a)) => a.ip().to_string(),
|
||||||
_ => "Unable to resolve domain name.".to_string(),
|
_ => "Unable to resolve domain name.".to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (latest_release, latest_commit, latest_web_build) = get_release_info(has_http_access).await;
|
let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
|
||||||
|
let active_web_release = get_active_web_release();
|
||||||
|
let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release);
|
||||||
|
|
||||||
let ip_header_name = &ip_header.0.unwrap_or_default();
|
let ip_header_name = &ip_header.0.unwrap_or_default();
|
||||||
|
|
||||||
// Get current running versions
|
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
|
||||||
let web_vault_version = get_web_vault_version();
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
|
&FeatureFlagFilter::InvalidOnly,
|
||||||
// Check if the running version is newer than the latest stable released version
|
|
||||||
let web_vault_pre_release = if let Ok(web_ver_match) = semver::VersionReq::parse(&format!(">{latest_web_build}")) {
|
|
||||||
web_ver_match.matches(
|
|
||||||
&semver::Version::parse(&web_vault_version).unwrap_or_else(|_| semver::Version::parse("2025.1.1").unwrap()),
|
|
||||||
)
|
)
|
||||||
} else {
|
.into_keys()
|
||||||
error!("Unable to parse latest_web_build: '{latest_web_build}'");
|
.collect();
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
let diagnostics_json = json!({
|
let diagnostics_json = json!({
|
||||||
"dns_resolved": dns_resolved,
|
"dns_resolved": dns_resolved,
|
||||||
"current_release": VERSION,
|
"current_release": VERSION,
|
||||||
"latest_release": latest_release,
|
"latest_release": latest_vw_release,
|
||||||
"latest_commit": latest_commit,
|
"latest_commit": latest_vw_commit,
|
||||||
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
||||||
"web_vault_version": web_vault_version,
|
"active_web_release": active_web_release,
|
||||||
"latest_web_build": latest_web_build,
|
"latest_web_release": latest_web_release,
|
||||||
"web_vault_pre_release": web_vault_pre_release,
|
"web_vault_compare": web_vault_compare,
|
||||||
"running_within_container": running_within_container,
|
"running_within_container": running_within_container,
|
||||||
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
||||||
"has_http_access": has_http_access,
|
"has_http_access": has_http_access,
|
||||||
@@ -747,9 +767,10 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
|||||||
"uses_proxy": uses_proxy,
|
"uses_proxy": uses_proxy,
|
||||||
"enable_websocket": &CONFIG.enable_websocket(),
|
"enable_websocket": &CONFIG.enable_websocket(),
|
||||||
"db_type": *DB_TYPE,
|
"db_type": *DB_TYPE,
|
||||||
"db_version": get_sql_server_version(&mut conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
"tz_env": env::var("TZ").unwrap_or_default(),
|
"tz_env": env::var("TZ").unwrap_or_default(),
|
||||||
@@ -791,9 +812,9 @@ async fn delete_config(_token: AdminToken) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/config/backup_db", format = "application/json")]
|
#[post("/config/backup_db", format = "application/json")]
|
||||||
async fn backup_db(_token: AdminToken, mut conn: DbConn) -> ApiResult<String> {
|
fn backup_db(_token: AdminToken) -> ApiResult<String> {
|
||||||
if *CAN_BACKUP {
|
if *CAN_BACKUP {
|
||||||
match backup_database(&mut conn).await {
|
match backup_sqlite() {
|
||||||
Ok(f) => Ok(format!("Backup to '{f}' was successful")),
|
Ok(f) => Ok(format!("Backup to '{f}' was successful")),
|
||||||
Err(e) => err!(format!("Backup was unsuccessful {e}")),
|
Err(e) => err!(format!("Backup was unsuccessful {e}")),
|
||||||
}
|
}
|
||||||
@@ -811,43 +832,65 @@ impl<'r> FromRequest<'r> for AdminToken {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.disable_admin_token() {
|
if !CONFIG.disable_admin_token() {
|
||||||
Outcome::Success(Self {
|
|
||||||
ip,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
let cookies = request.cookies();
|
let cookies = request.cookies();
|
||||||
|
|
||||||
let access_token = match cookies.get(COOKIE_NAME) {
|
let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
|
||||||
Some(cookie) => cookie.value(),
|
cookie.value()
|
||||||
None => {
|
} else {
|
||||||
let requested_page =
|
let requested_page =
|
||||||
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
|
||||||
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
// When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
|
||||||
// Else, return a 401 failure, which will be caught
|
// Else, return a 401 failure, which will be caught
|
||||||
if requested_page.is_empty() {
|
if requested_page.is_empty() {
|
||||||
return Outcome::Forward(Status::Unauthorized);
|
return Outcome::Forward(Status::Unauthorized);
|
||||||
} else {
|
}
|
||||||
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
return Outcome::Error((Status::Unauthorized, "Unauthorized"));
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if decode_admin(access_token).is_err() {
|
if decode_admin(access_token).is_err() {
|
||||||
// Remove admin cookie
|
// Remove admin cookie
|
||||||
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
|
||||||
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
|
error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
|
||||||
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
return Outcome::Error((Status::Unauthorized, "Session expired"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
ip,
|
ip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_web_vault_compare() {
|
||||||
|
// web_vault_compare(active, latest)
|
||||||
|
// Test normal versions
|
||||||
|
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
|
||||||
|
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
|
||||||
|
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
|
||||||
|
|
||||||
|
// Test patched/+build.n versions
|
||||||
|
// Newer latest version
|
||||||
|
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
|
||||||
|
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
|
||||||
|
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
|
||||||
|
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
|
||||||
|
// Equal versions
|
||||||
|
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
|
||||||
|
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
|
||||||
|
// Newer active version
|
||||||
|
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
|
||||||
|
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
|
||||||
|
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
|
||||||
|
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+419
-259
File diff suppressed because it is too large
Load Diff
+542
-310
File diff suppressed because it is too large
Load Diff
+118
-121
@@ -1,17 +1,23 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{CipherSyncData, CipherSyncType},
|
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
|
core::{CipherSyncData, CipherSyncType},
|
||||||
|
},
|
||||||
|
auth::{Headers, decode_emergency_access_invite},
|
||||||
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
|
models::{
|
||||||
|
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||||
|
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::{decode_emergency_access_invite, Headers},
|
|
||||||
db::{models::*, DbConn, DbPool},
|
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -40,29 +46,16 @@ pub fn routes() -> Vec<Route> {
|
|||||||
// region get
|
// region get
|
||||||
|
|
||||||
#[get("/emergency-access/trusted")]
|
#[get("/emergency-access/trusted")]
|
||||||
async fn get_contacts(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
async fn get_contacts(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
if !CONFIG.emergency_access_allowed() {
|
let emergency_access_list = if CONFIG.emergency_access_allowed() {
|
||||||
return Json(json!({
|
EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await
|
||||||
"data": [{
|
} else {
|
||||||
"id": "",
|
Vec::new()
|
||||||
"status": 2,
|
};
|
||||||
"type": 0,
|
|
||||||
"waitTimeDays": 0,
|
|
||||||
"granteeId": "",
|
|
||||||
"email": "",
|
|
||||||
"name": "NOTE: Emergency Access is disabled!",
|
|
||||||
"object": "emergencyAccessGranteeDetails",
|
|
||||||
|
|
||||||
}],
|
|
||||||
"object": "list",
|
|
||||||
"continuationToken": null
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &mut conn).await;
|
|
||||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||||
for ea in emergency_access_list {
|
for ea in emergency_access_list {
|
||||||
if let Some(grantee) = ea.to_json_grantee_details(&mut conn).await {
|
if let Some(grantee) = ea.to_json_grantee_details(&conn).await {
|
||||||
emergency_access_list_json.push(grantee)
|
emergency_access_list_json.push(grantee);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,15 +67,15 @@ async fn get_contacts(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/emergency-access/granted")]
|
#[get("/emergency-access/granted")]
|
||||||
async fn get_grantees(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
async fn get_grantees(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let emergency_access_list = if CONFIG.emergency_access_allowed() {
|
let emergency_access_list = if CONFIG.emergency_access_allowed() {
|
||||||
EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &conn).await
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len());
|
||||||
for ea in emergency_access_list {
|
for ea in emergency_access_list {
|
||||||
emergency_access_list_json.push(ea.to_json_grantor_details(&mut conn).await);
|
emergency_access_list_json.push(ea.to_json_grantor_details(&conn).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -93,14 +86,17 @@ async fn get_grantees(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/emergency-access/<emer_id>")]
|
#[get("/emergency-access/<emer_id>")]
|
||||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await {
|
if let Some(emergency_access) =
|
||||||
Some(emergency_access) => Ok(Json(
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
emergency_access.to_json_grantee_details(&mut conn).await.expect("Grantee user should exist but does not!"),
|
{
|
||||||
)),
|
Ok(Json(
|
||||||
None => err!("Emergency access not valid."),
|
emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,21 +127,22 @@ async fn post_emergency_access(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
data: Json<EmergencyAccessUpdateData>,
|
data: Json<EmergencyAccessUpdateData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.atype = new_type;
|
emergency_access.atype = new_type;
|
||||||
@@ -154,7 +151,7 @@ async fn post_emergency_access(
|
|||||||
emergency_access.key_encrypted = data.key_encrypted;
|
emergency_access.key_encrypted = data.key_encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
emergency_access.save(&mut conn).await?;
|
emergency_access.save(&conn).await?;
|
||||||
Ok(Json(emergency_access.to_json()))
|
Ok(Json(emergency_access.to_json()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +160,12 @@ async fn post_emergency_access(
|
|||||||
// region delete
|
// region delete
|
||||||
|
|
||||||
#[delete("/emergency-access/<emer_id>")]
|
#[delete("/emergency-access/<emer_id>")]
|
||||||
async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let emergency_access = match (
|
let emergency_access = match (
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await,
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await,
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &mut conn).await,
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await,
|
||||||
) {
|
) {
|
||||||
(Some(grantor_emer), None) => {
|
(Some(grantor_emer), None) => {
|
||||||
info!("Grantor deleted emergency access {emer_id}");
|
info!("Grantor deleted emergency access {emer_id}");
|
||||||
@@ -181,7 +178,7 @@ async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, m
|
|||||||
_ => err!("Emergency access not valid."),
|
_ => err!("Emergency access not valid."),
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.delete(&mut conn).await?;
|
emergency_access.delete(&conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +200,7 @@ struct EmergencyAccessInviteData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/invite", data = "<data>")]
|
#[post("/emergency-access/invite", data = "<data>")]
|
||||||
async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let data: EmergencyAccessInviteData = data.into_inner();
|
let data: EmergencyAccessInviteData = data.into_inner();
|
||||||
@@ -212,9 +209,10 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
|
|
||||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
Some(new_type) => new_type as i32,
|
new_type as i32
|
||||||
None => err!("Invalid emergency access type."),
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let grantor_user = headers.user;
|
let grantor_user = headers.user;
|
||||||
@@ -224,7 +222,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
err!("You can not set yourself as an emergency contact.")
|
err!("You can not set yourself as an emergency contact.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let (grantee_user, new_user) = match User::find_by_mail(&email, &mut conn).await {
|
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
|
||||||
None => {
|
None => {
|
||||||
if !CONFIG.invitations_allowed() {
|
if !CONFIG.invitations_allowed() {
|
||||||
err!(format!("Grantee user does not exist: {email}"))
|
err!(format!("Grantee user does not exist: {email}"))
|
||||||
@@ -236,11 +234,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
let invitation = Invitation::new(&email);
|
let invitation = Invitation::new(&email);
|
||||||
invitation.save(&mut conn).await?;
|
invitation.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(email.clone(), None);
|
let mut user = User::new(&email, None);
|
||||||
user.save(&mut conn).await?;
|
user.save(&conn).await?;
|
||||||
(user, true)
|
(user, true)
|
||||||
}
|
}
|
||||||
Some(user) if user.password_hash.is_empty() => (user, true),
|
Some(user) if user.password_hash.is_empty() => (user, true),
|
||||||
@@ -251,7 +249,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
&grantor_user.uuid,
|
&grantor_user.uuid,
|
||||||
&grantee_user.uuid,
|
&grantee_user.uuid,
|
||||||
&grantee_user.email,
|
&grantee_user.email,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_some()
|
.is_some()
|
||||||
@@ -261,7 +259,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
|
|
||||||
let mut new_emergency_access =
|
let mut new_emergency_access =
|
||||||
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
||||||
new_emergency_access.save(&mut conn).await?;
|
new_emergency_access.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_invite(
|
mail::send_emergency_access_invite(
|
||||||
@@ -274,18 +272,18 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
|||||||
.await?;
|
.await?;
|
||||||
} else if !new_user {
|
} else if !new_user {
|
||||||
// if mail is not enabled immediately accept the invitation for existing users
|
// if mail is not enabled immediately accept the invitation for existing users
|
||||||
new_emergency_access.accept_invite(&grantee_user.uuid, &email, &mut conn).await?;
|
new_emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/reinvite")]
|
#[post("/emergency-access/<emer_id>/reinvite")]
|
||||||
async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -298,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, mut conn: D
|
|||||||
err!("Email not valid.")
|
err!("Email not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(grantee_user) = User::find_by_mail(&email, &mut conn).await else {
|
let Some(grantee_user) = User::find_by_mail(&email, &conn).await else {
|
||||||
err!("Grantee user not found.")
|
err!("Grantee user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,10 +313,10 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, mut conn: D
|
|||||||
.await?;
|
.await?;
|
||||||
} else if !grantee_user.password_hash.is_empty() {
|
} else if !grantee_user.password_hash.is_empty() {
|
||||||
// accept the invitation for existing user
|
// accept the invitation for existing user
|
||||||
emergency_access.accept_invite(&grantee_user.uuid, &email, &mut conn).await?;
|
emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?;
|
||||||
} else if CONFIG.invitations_allowed() && Invitation::find_by_mail(&email, &mut conn).await.is_none() {
|
} else if CONFIG.invitations_allowed() && Invitation::find_by_mail(&email, &conn).await.is_none() {
|
||||||
let invitation = Invitation::new(&email);
|
let invitation = Invitation::new(&email);
|
||||||
invitation.save(&mut conn).await?;
|
invitation.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -335,7 +333,7 @@ async fn accept_invite(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
data: Json<AcceptData>,
|
data: Json<AcceptData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
@@ -349,24 +347,23 @@ async fn accept_invite(
|
|||||||
err!("Claim email does not match current users email")
|
err!("Claim email does not match current users email")
|
||||||
}
|
}
|
||||||
|
|
||||||
let grantee_user = match User::find_by_mail(&claims.email, &mut conn).await {
|
let grantee_user = if let Some(user) = User::find_by_mail(&claims.email, &conn).await {
|
||||||
Some(user) => {
|
Invitation::take(&claims.email, &conn).await;
|
||||||
Invitation::take(&claims.email, &mut conn).await;
|
|
||||||
user
|
user
|
||||||
}
|
} else {
|
||||||
None => err!("Invited user not found"),
|
err!("Invited user not found")
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
// We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database.
|
||||||
// The uuid of the grantee gets stored once accepted.
|
// The uuid of the grantee gets stored once accepted.
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_email(&emer_id, &headers.user.email, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_email(&emer_id, &headers.user.email, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
// get grantor user to send Accepted email
|
// get grantor user to send Accepted email
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -374,7 +371,7 @@ async fn accept_invite(
|
|||||||
&& grantor_user.name == claims.grantor_name
|
&& grantor_user.name == claims.grantor_name
|
||||||
&& grantor_user.email == claims.grantor_email
|
&& grantor_user.email == claims.grantor_email
|
||||||
{
|
{
|
||||||
emergency_access.accept_invite(&grantee_user.uuid, &grantee_user.email, &mut conn).await?;
|
emergency_access.accept_invite(&grantee_user.uuid, &grantee_user.email, &conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email).await?;
|
mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email).await?;
|
||||||
@@ -397,7 +394,7 @@ async fn confirm_emergency_access(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
data: Json<ConfirmData>,
|
data: Json<ConfirmData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
@@ -406,7 +403,7 @@ async fn confirm_emergency_access(
|
|||||||
let key = data.key;
|
let key = data.key;
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &confirming_user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &confirming_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -417,12 +414,12 @@ async fn confirm_emergency_access(
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&confirming_user.uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&confirming_user.uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
err!("Grantee user not found.")
|
err!("Grantee user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -430,7 +427,7 @@ async fn confirm_emergency_access(
|
|||||||
emergency_access.key_encrypted = Some(key);
|
emergency_access.key_encrypted = Some(key);
|
||||||
emergency_access.email = None;
|
emergency_access.email = None;
|
||||||
|
|
||||||
emergency_access.save(&mut conn).await?;
|
emergency_access.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name).await?;
|
mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name).await?;
|
||||||
@@ -446,12 +443,12 @@ async fn confirm_emergency_access(
|
|||||||
// region access emergency access
|
// region access emergency access
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/initiate")]
|
#[post("/emergency-access/<emer_id>/initiate")]
|
||||||
async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let initiating_user = headers.user;
|
let initiating_user = headers.user;
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -460,7 +457,7 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -469,7 +466,7 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
emergency_access.updated_at = now;
|
emergency_access.updated_at = now;
|
||||||
emergency_access.recovery_initiated_at = Some(now);
|
emergency_access.recovery_initiated_at = Some(now);
|
||||||
emergency_access.last_notification_at = Some(now);
|
emergency_access.last_notification_at = Some(now);
|
||||||
emergency_access.save(&mut conn).await?;
|
emergency_access.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_recovery_initiated(
|
mail::send_emergency_access_recovery_initiated(
|
||||||
@@ -484,11 +481,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/approve")]
|
#[post("/emergency-access/<emer_id>/approve")]
|
||||||
async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -497,17 +494,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
err!("Grantee user not found.")
|
err!("Grantee user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32;
|
emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||||
emergency_access.save(&mut conn).await?;
|
emergency_access.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await?;
|
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await?;
|
||||||
@@ -519,11 +516,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/reject")]
|
#[post("/emergency-access/<emer_id>/reject")]
|
||||||
async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -535,12 +532,12 @@ async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
||||||
err!("Grantee user not found.")
|
err!("Grantee user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
||||||
emergency_access.save(&mut conn).await?;
|
emergency_access.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_emergency_access_recovery_rejected(&grantee_user.email, &headers.user.name).await?;
|
mail::send_emergency_access_recovery_rejected(&grantee_user.email, &headers.user.name).await?;
|
||||||
@@ -556,11 +553,11 @@ async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, m
|
|||||||
// region action
|
// region action
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/view")]
|
#[post("/emergency-access/<emer_id>/view")]
|
||||||
async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -569,8 +566,8 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &mut conn).await;
|
let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &conn).await;
|
||||||
let cipher_sync_data = CipherSyncData::new(&emergency_access.grantor_uuid, CipherSyncType::User, &mut conn).await;
|
let cipher_sync_data = CipherSyncData::new(&emergency_access.grantor_uuid, CipherSyncType::User, &conn).await;
|
||||||
|
|
||||||
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
||||||
for c in ciphers {
|
for c in ciphers {
|
||||||
@@ -580,7 +577,7 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut
|
|||||||
&emergency_access.grantor_uuid,
|
&emergency_access.grantor_uuid,
|
||||||
Some(&cipher_sync_data),
|
Some(&cipher_sync_data),
|
||||||
CipherSyncType::User,
|
CipherSyncType::User,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
@@ -594,12 +591,12 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/takeover")]
|
#[post("/emergency-access/<emer_id>/takeover")]
|
||||||
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let requesting_user = headers.user;
|
let requesting_user = headers.user;
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -608,7 +605,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -636,7 +633,7 @@ async fn password_emergency_access(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
data: Json<EmergencyAccessPasswordData>,
|
data: Json<EmergencyAccessPasswordData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
@@ -646,7 +643,7 @@ async fn password_emergency_access(
|
|||||||
|
|
||||||
let requesting_user = headers.user;
|
let requesting_user = headers.user;
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -655,21 +652,21 @@ async fn password_emergency_access(
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(mut grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
let Some(mut grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
// change grantor_user password
|
// change grantor_user password
|
||||||
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None);
|
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
|
||||||
grantor_user.save(&mut conn).await?;
|
grantor_user.save(&conn).await?;
|
||||||
|
|
||||||
// Disable TwoFactor providers since they will otherwise block logins
|
// Disable TwoFactor providers since they will otherwise block logins
|
||||||
TwoFactor::delete_all_by_user(&grantor_user.uuid, &mut conn).await?;
|
TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn).await?;
|
||||||
|
|
||||||
// Remove grantor from all organisations unless Owner
|
// Remove grantor from all organisations unless Owner
|
||||||
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &mut conn).await {
|
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await {
|
||||||
if member.atype != MembershipType::Owner as i32 {
|
if member.atype != MembershipType::Owner as i32 {
|
||||||
member.delete(&mut conn).await?;
|
member.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -678,10 +675,10 @@ async fn password_emergency_access(
|
|||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
#[get("/emergency-access/<emer_id>/policies")]
|
#[get("/emergency-access/<emer_id>/policies")]
|
||||||
async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let requesting_user = headers.user;
|
let requesting_user = headers.user;
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &mut conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
@@ -690,11 +687,11 @@ async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
err!("Grantor user not found.")
|
err!("Grantor user not found.")
|
||||||
};
|
};
|
||||||
|
|
||||||
let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &mut conn);
|
let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &conn);
|
||||||
let policies_json: Vec<Value> = policies.await.iter().map(OrgPolicy::to_json).collect();
|
let policies_json: Vec<Value> = policies.await.iter().map(OrgPolicy::to_json).collect();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -728,8 +725,8 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&mut conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request timeout to approve");
|
debug!("No emergency request timeout to approve");
|
||||||
@@ -743,18 +740,18 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
if recovery_allowed_at.le(&now) {
|
if recovery_allowed_at.le(&now) {
|
||||||
// Only update the access status
|
// Only update the access status
|
||||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||||
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &mut conn)
|
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to update emergency access status");
|
.expect("Unable to update emergency access status");
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
// get grantor user to send Accepted email
|
// get grantor user to send Accepted email
|
||||||
let grantor_user =
|
let grantor_user =
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &mut conn).await.expect("Grantor user not found");
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
|
|
||||||
// get grantee user to send Accepted email
|
// get grantee user to send Accepted email
|
||||||
let grantee_user =
|
let grantee_user =
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &mut conn)
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
.await
|
.await
|
||||||
.expect("Grantee user not found");
|
.expect("Grantee user not found");
|
||||||
|
|
||||||
@@ -773,7 +770,7 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency request timed out")
|
error!("Failed to get DB connection while searching emergency request timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,8 +780,8 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&mut conn).await;
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
|
|
||||||
if emergency_access_list.is_empty() {
|
if emergency_access_list.is_empty() {
|
||||||
debug!("No emergency request reminder notification to send");
|
debug!("No emergency request reminder notification to send");
|
||||||
@@ -805,18 +802,18 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
||||||
// Only update the last notification date
|
// Only update the last notification date
|
||||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
||||||
emer.update_last_notification_date_and_save(&now, &mut conn)
|
emer.update_last_notification_date_and_save(&now, &conn)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to update emergency access notification date");
|
.expect("Unable to update emergency access notification date");
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
// get grantor user to send Accepted email
|
// get grantor user to send Accepted email
|
||||||
let grantor_user =
|
let grantor_user =
|
||||||
User::find_by_uuid(&emer.grantor_uuid, &mut conn).await.expect("Grantor user not found");
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
|
|
||||||
// get grantee user to send Accepted email
|
// get grantee user to send Accepted email
|
||||||
let grantee_user =
|
let grantee_user =
|
||||||
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &mut conn)
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
.await
|
.await
|
||||||
.expect("Grantee user not found");
|
.expect("Grantee user not found");
|
||||||
|
|
||||||
@@ -832,6 +829,6 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while searching emergency notification reminder")
|
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-66
@@ -1,18 +1,18 @@
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use rocket::{form::FromForm, serde::json::Json, Route};
|
use rocket::{Route, form::FromForm, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{EmptyResult, JsonResult},
|
api::{EmptyResult, JsonResult},
|
||||||
auth::{AdminHeaders, Headers},
|
auth::{AdminHeaders, Headers},
|
||||||
db::{
|
db::{
|
||||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||||
},
|
},
|
||||||
util::parse_date,
|
util::parse_date,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// ###############################################################################################################
|
/// ###############################################################################################################
|
||||||
@@ -31,21 +31,14 @@ struct EventRange {
|
|||||||
|
|
||||||
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
|
||||||
#[get("/organizations/<org_id>/events?<data..>")]
|
#[get("/organizations/<org_id>/events?<data..>")]
|
||||||
async fn get_org_events(
|
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
||||||
org_id: OrganizationId,
|
|
||||||
data: EventRange,
|
|
||||||
headers: AdminHeaders,
|
|
||||||
mut conn: DbConn,
|
|
||||||
) -> JsonResult {
|
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -53,11 +46,13 @@ async fn get_org_events(
|
|||||||
parse_date(&data.end)
|
parse_date(&data.end)
|
||||||
};
|
};
|
||||||
|
|
||||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &mut conn)
|
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -68,14 +63,12 @@ async fn get_org_events(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
|
||||||
Vec::with_capacity(0)
|
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
|
||||||
} else {
|
{
|
||||||
let mut events_json = Vec::with_capacity(0);
|
|
||||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &mut conn).await {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -83,13 +76,9 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
|
|||||||
parse_date(&data.end)
|
parse_date(&data.end)
|
||||||
};
|
};
|
||||||
|
|
||||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &mut conn)
|
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
|
||||||
.await
|
} else {
|
||||||
.iter()
|
Vec::new()
|
||||||
.map(|e| e.to_json())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
events_json
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -105,16 +94,14 @@ async fn get_user_events(
|
|||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
data: EventRange,
|
data: EventRange,
|
||||||
headers: AdminHeaders,
|
headers: AdminHeaders,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
if org_id != headers.org_id {
|
if org_id != headers.org_id {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
// Return an empty vec when we org events are disabled.
|
// Return an empty vec when we org events are disabled.
|
||||||
// This prevents client errors
|
// This prevents client errors
|
||||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
|
||||||
Vec::with_capacity(0)
|
|
||||||
} else {
|
|
||||||
let start_date = parse_date(&data.start);
|
let start_date = parse_date(&data.start);
|
||||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||||
parse_date(before_date)
|
parse_date(before_date)
|
||||||
@@ -122,11 +109,13 @@ async fn get_user_events(
|
|||||||
parse_date(&data.end)
|
parse_date(&data.end)
|
||||||
};
|
};
|
||||||
|
|
||||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &mut conn)
|
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| e.to_json())
|
.map(Event::to_json)
|
||||||
.collect()
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -139,7 +128,8 @@ async fn get_user_events(
|
|||||||
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
fn get_continuation_token(events_json: &[Value]) -> Option<&str> {
|
||||||
// When the length of the vec equals the max page_size there probably is more data
|
// When the length of the vec equals the max page_size there probably is more data
|
||||||
// When it is less, then all events are loaded.
|
// When it is less, then all events are loaded.
|
||||||
if events_json.len() as i64 == Event::PAGE_SIZE {
|
#[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")]
|
||||||
|
if events_json.len() == Event::PAGE_SIZE as usize {
|
||||||
if let Some(last_event) = events_json.last() {
|
if let Some(last_event) = events_json.last() {
|
||||||
last_event["date"].as_str()
|
last_event["date"].as_str()
|
||||||
} else {
|
} else {
|
||||||
@@ -172,7 +162,7 @@ struct EventCollection {
|
|||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
|
||||||
#[post("/collect", format = "application/json", data = "<data>")]
|
#[post("/collect", format = "application/json", data = "<data>")]
|
||||||
async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -181,19 +171,22 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
let event_date = parse_date(&event.date);
|
let event_date = parse_date(&event.date);
|
||||||
match event.r#type {
|
match event.r#type {
|
||||||
1000..=1099 => {
|
1000..=1099 => {
|
||||||
_log_user_event(
|
log_user_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
Some(event_date),
|
Some(event_date),
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
1600..=1699 => {
|
1600..=1699 => {
|
||||||
if let Some(org_id) = &event.organization_id {
|
// Only allow logging events for an organization the user is actually a member of.
|
||||||
_log_event(
|
if let Some(org_id) = &event.organization_id
|
||||||
|
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
org_id,
|
org_id,
|
||||||
org_id,
|
org_id,
|
||||||
@@ -201,16 +194,20 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
Some(event_date),
|
Some(event_date),
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(cipher_uuid) = &event.cipher_id {
|
// The cipher determines the organization the event is logged to, so make sure the
|
||||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &mut conn).await {
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(org_id) = cipher.organization_uuid {
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
_log_event(
|
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
|
||||||
|
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
||||||
|
&& let Some(org_id) = cipher.organization_uuid
|
||||||
|
{
|
||||||
|
log_event_impl(
|
||||||
event.r#type,
|
event.r#type,
|
||||||
cipher_uuid,
|
cipher_uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
@@ -218,34 +215,32 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
Some(event_date),
|
Some(event_date),
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &mut DbConn) {
|
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
|
||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _log_user_event(
|
async fn log_user_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
event_date: Option<NaiveDateTime>,
|
event_date: Option<NaiveDateTime>,
|
||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
|
||||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||||
|
|
||||||
// Upstream saves the event also without any org_id.
|
// Upstream saves the event also without any org_id.
|
||||||
@@ -278,16 +273,16 @@ pub async fn log_event(
|
|||||||
act_user_id: &UserId,
|
act_user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
if !CONFIG.org_events_enabled() {
|
if !CONFIG.org_events_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[expect(clippy::too_many_arguments)]
|
||||||
async fn _log_event(
|
async fn log_event_impl(
|
||||||
event_type: i32,
|
event_type: i32,
|
||||||
source_uuid: &str,
|
source_uuid: &str,
|
||||||
org_id: &OrganizationId,
|
org_id: &OrganizationId,
|
||||||
@@ -295,7 +290,7 @@ async fn _log_event(
|
|||||||
device_type: i32,
|
device_type: i32,
|
||||||
event_date: Option<NaiveDateTime>,
|
event_date: Option<NaiveDateTime>,
|
||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
// Create a new empty event
|
// Create a new empty event
|
||||||
let mut event = Event::new(event_type, event_date);
|
let mut event = Event::new(event_type, event_date);
|
||||||
@@ -303,24 +298,24 @@ async fn _log_event(
|
|||||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||||
// Cipher Events
|
// Cipher Events
|
||||||
1100..=1199 => {
|
1100..=1199 => {
|
||||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
event.cipher_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Collection Events
|
// Collection Events
|
||||||
1300..=1399 => {
|
1300..=1399 => {
|
||||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
event.collection_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Group Events
|
// Group Events
|
||||||
1400..=1499 => {
|
1400..=1499 => {
|
||||||
event.group_uuid = Some(source_uuid.to_string().into());
|
event.group_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Org User Events
|
// Org User Events
|
||||||
1500..=1599 => {
|
1500..=1599 => {
|
||||||
event.org_user_uuid = Some(source_uuid.to_string().into());
|
event.org_user_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
||||||
// Policy Events
|
// Policy Events
|
||||||
1700..=1799 => {
|
1700..=1799 => {
|
||||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
event.policy_uuid = Some(source_uuid.to_owned().into());
|
||||||
}
|
}
|
||||||
// Ignore others
|
// Ignore others
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -340,9 +335,9 @@ pub async fn event_cleanup_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Event::clean_events(&mut conn).await.ok();
|
Event::clean_events(&conn).await.ok();
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table")
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-18
@@ -4,7 +4,11 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{models::*, DbConn},
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Folder, FolderId},
|
||||||
|
},
|
||||||
|
util::deser_opt_nonempty_str,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<rocket::Route> {
|
pub fn routes() -> Vec<rocket::Route> {
|
||||||
@@ -12,8 +16,8 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/folders")]
|
#[get("/folders")]
|
||||||
async fn get_folders(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
async fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let folders = Folder::find_by_user(&headers.user.uuid, &mut conn).await;
|
let folders = Folder::find_by_user(&headers.user.uuid, &conn).await;
|
||||||
let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect();
|
let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -24,10 +28,11 @@ async fn get_folders(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/folders/<folder_id>")]
|
#[get("/folders/<folder_id>")]
|
||||||
async fn get_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &mut conn).await {
|
if let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await {
|
||||||
Some(folder) => Ok(Json(folder.to_json())),
|
Ok(Json(folder.to_json()))
|
||||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
} else {
|
||||||
|
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,17 +40,18 @@ async fn get_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn) ->
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FolderData {
|
pub struct FolderData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
|
||||||
pub id: Option<FolderId>,
|
pub id: Option<FolderId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/folders", data = "<data>")]
|
#[post("/folders", data = "<data>")]
|
||||||
async fn post_folders(data: Json<FolderData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_folders(data: Json<FolderData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
let data: FolderData = data.into_inner();
|
let data: FolderData = data.into_inner();
|
||||||
|
|
||||||
let mut folder = Folder::new(headers.user.uuid, data.name);
|
let mut folder = Folder::new(headers.user.uuid, data.name);
|
||||||
|
|
||||||
folder.save(&mut conn).await?;
|
folder.save(&conn).await?;
|
||||||
nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device, &mut conn).await;
|
nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device, &conn).await;
|
||||||
|
|
||||||
Ok(Json(folder.to_json()))
|
Ok(Json(folder.to_json()))
|
||||||
}
|
}
|
||||||
@@ -66,19 +72,19 @@ async fn put_folder(
|
|||||||
folder_id: FolderId,
|
folder_id: FolderId,
|
||||||
data: Json<FolderData>,
|
data: Json<FolderData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: FolderData = data.into_inner();
|
let data: FolderData = data.into_inner();
|
||||||
|
|
||||||
let Some(mut folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &mut conn).await else {
|
let Some(mut folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||||
};
|
};
|
||||||
|
|
||||||
folder.name = data.name;
|
folder.name = data.name;
|
||||||
|
|
||||||
folder.save(&mut conn).await?;
|
folder.save(&conn).await?;
|
||||||
nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device, &mut conn).await;
|
nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device, &conn).await;
|
||||||
|
|
||||||
Ok(Json(folder.to_json()))
|
Ok(Json(folder.to_json()))
|
||||||
}
|
}
|
||||||
@@ -89,14 +95,14 @@ async fn delete_folder_post(folder_id: FolderId, headers: Headers, conn: DbConn,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/folders/<folder_id>")]
|
#[delete("/folders/<folder_id>")]
|
||||||
async fn delete_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_folder(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &mut conn).await else {
|
let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete the actual folder entry
|
// Delete the actual folder entry
|
||||||
folder.delete(&mut conn).await?;
|
folder.delete(&conn).await?;
|
||||||
|
|
||||||
nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device, &mut conn).await;
|
nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device, &conn).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-70
@@ -1,4 +1,6 @@
|
|||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod two_factor;
|
||||||
|
|
||||||
mod ciphers;
|
mod ciphers;
|
||||||
mod emergency_access;
|
mod emergency_access;
|
||||||
mod events;
|
mod events;
|
||||||
@@ -6,17 +8,32 @@ mod folders;
|
|||||||
mod organizations;
|
mod organizations;
|
||||||
mod public;
|
mod public;
|
||||||
mod sends;
|
mod sends;
|
||||||
pub mod two_factor;
|
|
||||||
|
|
||||||
pub use accounts::purge_auth_requests;
|
pub use accounts::purge_auth_requests;
|
||||||
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
|
pub use ciphers::{CipherData, CipherSyncData, CipherSyncType, purge_trashed_ciphers};
|
||||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||||
use reqwest::Method;
|
|
||||||
pub use sends::purge_sends;
|
pub use sends::purge_sends;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
use rocket::{Catcher, Route, serde::json::Json, serde::json::Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
|
auth::Headers,
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
http_client::make_http_request,
|
||||||
|
mail,
|
||||||
|
util::{FeatureFlagFilter, parse_experimental_client_feature_flags},
|
||||||
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
let mut eq_domains_routes = routes![get_settings_domains, post_settings_domains, put_settings_domains];
|
||||||
let mut hibp_routes = routes![hibp_breach];
|
let mut hibp_routes = routes![hibp_breach];
|
||||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||||
|
|
||||||
@@ -44,21 +61,6 @@ pub fn events_routes() -> Vec<Route> {
|
|||||||
routes
|
routes
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// Move this somewhere else
|
|
||||||
//
|
|
||||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
|
||||||
auth::Headers,
|
|
||||||
db::{models::*, DbConn},
|
|
||||||
error::Error,
|
|
||||||
http_client::make_http_request,
|
|
||||||
mail,
|
|
||||||
util::parse_experimental_client_feature_flags,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GlobalDomain {
|
struct GlobalDomain {
|
||||||
@@ -69,15 +71,17 @@ struct GlobalDomain {
|
|||||||
|
|
||||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||||
|
|
||||||
|
#[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")]
|
||||||
#[get("/settings/domains")]
|
#[get("/settings/domains")]
|
||||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
fn get_settings_domains(headers: Headers) -> Json<Value> {
|
||||||
_get_eq_domains(headers, false)
|
get_eq_domains(&headers, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> {
|
fn get_eq_domains(headers: &Headers, no_excluded: bool) -> Json<Value> {
|
||||||
let user = headers.user;
|
|
||||||
use serde_json::from_str;
|
use serde_json::from_str;
|
||||||
|
|
||||||
|
let user = &headers.user;
|
||||||
|
|
||||||
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
|
||||||
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
|
||||||
|
|
||||||
@@ -106,39 +110,45 @@ struct EquivDomainData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/settings/domains", data = "<data>")]
|
#[post("/settings/domains", data = "<data>")]
|
||||||
async fn post_eq_domains(
|
async fn post_settings_domains(
|
||||||
data: Json<EquivDomainData>,
|
data: Json<EquivDomainData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
use serde_json::to_string;
|
||||||
|
|
||||||
let data: EquivDomainData = data.into_inner();
|
let data: EquivDomainData = data.into_inner();
|
||||||
|
|
||||||
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
|
||||||
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
use serde_json::to_string;
|
|
||||||
|
|
||||||
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
|
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_owned());
|
||||||
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
|
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_owned());
|
||||||
|
|
||||||
user.save(&mut conn).await?;
|
user.save(&conn).await?;
|
||||||
|
|
||||||
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &mut conn).await;
|
nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({})))
|
Ok(Json(json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/settings/domains", data = "<data>")]
|
#[put("/settings/domains", data = "<data>")]
|
||||||
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn put_settings_domains(
|
||||||
post_eq_domains(data, headers, conn, nt).await
|
data: Json<EquivDomainData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
post_settings_domains(data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/hibp/breach?<username>")]
|
#[get("/hibp/breach?<username>")]
|
||||||
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
||||||
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
||||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
if let Some(api_key) = CONFIG.hibp_api_key() {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||||
);
|
);
|
||||||
@@ -199,19 +209,17 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
|||||||
|
|
||||||
#[get("/config")]
|
#[get("/config")]
|
||||||
fn config() -> Json<Value> {
|
fn config() -> Json<Value> {
|
||||||
let domain = crate::CONFIG.domain();
|
let domain = CONFIG.domain();
|
||||||
// Official available feature flags can be found here:
|
// Official available feature flags can be found here:
|
||||||
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103
|
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
|
||||||
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12
|
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
|
||||||
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
|
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
|
||||||
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
|
||||||
let mut feature_states =
|
let mut feature_states = parse_experimental_client_feature_flags(
|
||||||
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
|
&CONFIG.experimental_client_feature_flags(),
|
||||||
feature_states.insert("duo-redirect".to_string(), true);
|
&FeatureFlagFilter::ValidOnly,
|
||||||
feature_states.insert("email-verification".to_string(), true);
|
);
|
||||||
feature_states.insert("unauth-ui-refresh".to_string(), true);
|
feature_states.insert("pm-19148-innovation-archive".to_owned(), true);
|
||||||
feature_states.insert("enable-pm-flight-recorder".to_string(), true);
|
|
||||||
feature_states.insert("mobile-error-reporting".to_string(), true);
|
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
// Note: The clients use this version to handle backwards compatibility concerns
|
// Note: The clients use this version to handle backwards compatibility concerns
|
||||||
@@ -219,14 +227,15 @@ fn config() -> Json<Value> {
|
|||||||
// We should make sure that we keep this updated when we support the new server features
|
// We should make sure that we keep this updated when we support the new server features
|
||||||
// Version history:
|
// Version history:
|
||||||
// - Individual cipher key encryption: 2024.2.0
|
// - Individual cipher key encryption: 2024.2.0
|
||||||
"version": "2025.6.0",
|
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
|
||||||
|
"version": "2025.12.0",
|
||||||
"gitHash": option_env!("GIT_REV"),
|
"gitHash": option_env!("GIT_REV"),
|
||||||
"server": {
|
"server": {
|
||||||
"name": "Vaultwarden",
|
"name": "Vaultwarden",
|
||||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"disableUserRegistration": crate::CONFIG.is_signup_disabled()
|
"disableUserRegistration": CONFIG.is_signup_disabled()
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": {
|
||||||
"vault": domain,
|
"vault": domain,
|
||||||
@@ -265,39 +274,23 @@ async fn accept_org_invite(
|
|||||||
user: &User,
|
user: &User,
|
||||||
mut member: Membership,
|
mut member: Membership,
|
||||||
reset_password_key: Option<String>,
|
reset_password_key: Option<String>,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
if member.status != MembershipStatus::Invited as i32 {
|
if member.status != MembershipStatus::Invited as i32 {
|
||||||
err!("User already accepted the invitation");
|
err!("User already accepted the invitation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
|
||||||
// It returns different error messages per function.
|
|
||||||
if member.atype < MembershipType::Admin {
|
|
||||||
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
|
||||||
if crate::CONFIG.email_2fa_auto_fallback() {
|
|
||||||
two_factor::email::activate_email_2fa(user, conn).await?;
|
|
||||||
} else {
|
|
||||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
|
||||||
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.status = MembershipStatus::Accepted as i32;
|
member.status = MembershipStatus::Accepted as i32;
|
||||||
member.reset_password_key = reset_password_key;
|
member.reset_password_key = reset_password_key;
|
||||||
|
|
||||||
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
|
OrgPolicy::check_user_allowed(&member, "join", conn).await?;
|
||||||
|
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
if crate::CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
let Some(org) = Organization::find_by_uuid(&member.org_uuid, conn).await else {
|
||||||
Some(org) => org,
|
err!("Organization not found.")
|
||||||
None => err!("Organization not found."),
|
|
||||||
};
|
};
|
||||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||||
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
||||||
|
|||||||
+668
-917
File diff suppressed because it is too large
Load Diff
+72
-65
@@ -1,17 +1,24 @@
|
|||||||
use chrono::Utc;
|
|
||||||
use rocket::{
|
|
||||||
request::{FromRequest, Outcome},
|
|
||||||
serde::json::Json,
|
|
||||||
Request, Route,
|
|
||||||
};
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{
|
||||||
|
Request, Route,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
serde::json::Json,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth,
|
auth,
|
||||||
db::{models::*, DbConn},
|
db::{
|
||||||
mail, CONFIG,
|
DbConn,
|
||||||
|
models::{
|
||||||
|
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
|
||||||
|
OrganizationApiKey, OrganizationId, User,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -44,7 +51,7 @@ struct OrgImportData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/public/organization/import", data = "<data>")]
|
#[post("/public/organization/import", data = "<data>")]
|
||||||
async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: DbConn) -> EmptyResult {
|
async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn) -> EmptyResult {
|
||||||
// Most of the logic for this function can be found here
|
// Most of the logic for this function can be found here
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs#L1203
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs#L1203
|
||||||
|
|
||||||
@@ -55,13 +62,12 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
|||||||
let mut user_created: bool = false;
|
let mut user_created: bool = false;
|
||||||
if user_data.deleted {
|
if user_data.deleted {
|
||||||
// If user is marked for deletion and it exists, revoke it
|
// If user is marked for deletion and it exists, revoke it
|
||||||
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await {
|
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||||
// Only revoke a user if it is not the last confirmed owner
|
// Only revoke a user if it is not the last confirmed owner
|
||||||
let revoked = if member.atype == MembershipType::Owner
|
let revoked = if member.atype == MembershipType::Owner
|
||||||
&& member.status == MembershipStatus::Confirmed as i32
|
&& member.status == MembershipStatus::Confirmed as i32
|
||||||
{
|
{
|
||||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &mut conn).await <= 1
|
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||||
{
|
|
||||||
warn!("Can't revoke the last owner");
|
warn!("Can't revoke the last owner");
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -73,31 +79,37 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
|||||||
|
|
||||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||||
if revoked || ext_modified {
|
if revoked || ext_modified {
|
||||||
member.save(&mut conn).await?;
|
member.save(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If user is part of the organization, restore it
|
// If user is part of the organization, restore it
|
||||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await {
|
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
|
||||||
let restored = member.restore();
|
let mut restored = member.restore();
|
||||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||||
|
// Enforce org policies as every other restore path does.
|
||||||
|
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
|
||||||
|
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
|
||||||
|
warn!("Not restoring {}: {e:?}", user_data.email);
|
||||||
|
member.revoke();
|
||||||
|
restored = false;
|
||||||
|
}
|
||||||
if restored || ext_modified {
|
if restored || ext_modified {
|
||||||
member.save(&mut conn).await?;
|
member.save(&conn).await?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If user is not part of the organization
|
// If user is not part of the organization
|
||||||
let user = match User::find_by_mail(&user_data.email, &mut conn).await {
|
let user = if let Some(user) = User::find_by_mail(&user_data.email, &conn).await {
|
||||||
Some(user) => user, // exists in vaultwarden
|
user
|
||||||
None => {
|
} else {
|
||||||
// User does not exist yet
|
// User does not exist yet
|
||||||
let mut new_user = User::new(user_data.email.clone(), None);
|
let mut new_user = User::new(&user_data.email, None);
|
||||||
new_user.save(&mut conn).await?;
|
new_user.save(&conn).await?;
|
||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
Invitation::new(&new_user.email).save(&mut conn).await?;
|
Invitation::new(&new_user.email).save(&conn).await?;
|
||||||
}
|
}
|
||||||
user_created = true;
|
user_created = true;
|
||||||
new_user
|
new_user
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||||
MembershipStatus::Invited as i32
|
MembershipStatus::Invited as i32
|
||||||
@@ -105,9 +117,10 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
|||||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||||
};
|
};
|
||||||
|
|
||||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
let (org_name, org_email) = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
|
||||||
Some(org) => (org.name, org.billing_email),
|
(org.name, org.billing_email)
|
||||||
None => err!("Error looking up organization"),
|
} else {
|
||||||
|
err!("Error looking up organization")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||||
@@ -116,48 +129,43 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
|||||||
new_member.atype = MembershipType::User as i32;
|
new_member.atype = MembershipType::User as i32;
|
||||||
new_member.status = member_status;
|
new_member.status = member_status;
|
||||||
|
|
||||||
new_member.save(&mut conn).await?;
|
new_member.save(&conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled()
|
||||||
if let Err(e) =
|
&& let Err(e) =
|
||||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||||
{
|
{
|
||||||
// Upon error delete the user, invite and org member records when needed
|
// Upon error delete the user, invite and org member records when needed
|
||||||
if user_created {
|
if user_created {
|
||||||
user.delete(&mut conn).await?;
|
user.delete(&conn).await?;
|
||||||
} else {
|
} else {
|
||||||
new_member.delete(&mut conn).await?;
|
new_member.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
err!(format!("Error sending invite: {e:?} "));
|
err!(format!("Error sending invite: {e:?} "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.org_groups_enabled() {
|
if CONFIG.org_groups_enabled() {
|
||||||
for group_data in &data.groups {
|
for group_data in &data.groups {
|
||||||
let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &mut conn).await
|
let group_uuid = if let Some(group) =
|
||||||
|
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(&mut conn).await?;
|
|
||||||
group.uuid
|
group.uuid
|
||||||
}
|
} else {
|
||||||
|
let mut group =
|
||||||
|
Group::new(org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()));
|
||||||
|
group.save(&conn).await?;
|
||||||
|
group.uuid
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?;
|
GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
|
||||||
|
|
||||||
for ext_id in &group_data.member_external_ids {
|
for ext_id in &group_data.member_external_ids {
|
||||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &mut conn).await {
|
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {
|
||||||
let mut group_user = GroupUser::new(group_uuid.clone(), member.uuid.clone());
|
let mut group_user = GroupUser::new(group_uuid.clone(), member.uuid.clone());
|
||||||
group_user.save(&mut conn).await?;
|
group_user.save(&conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,20 +177,18 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
|||||||
if data.overwrite_existing {
|
if data.overwrite_existing {
|
||||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||||
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
||||||
for member in Membership::find_by_org(&org_id, &mut conn).await {
|
for member in Membership::find_by_org(&org_id, &conn).await {
|
||||||
if let Some(ref user_external_id) = member.external_id {
|
if let Some(ref user_external_id) = member.external_id
|
||||||
if !sync_members.contains(user_external_id) {
|
&& !sync_members.contains(user_external_id)
|
||||||
|
{
|
||||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||||
// Removing owner, check that there is at least one other confirmed owner
|
// Removing owner, check that there is at least one other confirmed owner
|
||||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &mut conn).await
|
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
||||||
<= 1
|
|
||||||
{
|
|
||||||
warn!("Can't delete the last owner");
|
warn!("Can't delete the last owner");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
member.delete(&mut conn).await?;
|
member.delete(&conn).await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,12 +205,14 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||||
@@ -226,14 +234,13 @@ impl<'r> FromRequest<'r> for PublicToken {
|
|||||||
|
|
||||||
// Check if claims.sub is org_api_key.uuid
|
// Check if claims.sub is org_api_key.uuid
|
||||||
// Check if claims.client_sub is org_api_key.org_uuid
|
// Check if claims.client_sub is org_api_key.org_uuid
|
||||||
let conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
let Some(org_id) = claims.client_id.strip_prefix("organization.") else {
|
||||||
err_handler!("Malformed client_id")
|
err_handler!("Malformed client_id")
|
||||||
};
|
};
|
||||||
let org_id: OrganizationId = org_id.to_string().into();
|
let org_id: OrganizationId = org_id.to_owned().into();
|
||||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else {
|
||||||
err_handler!("Invalid client_id")
|
err_handler!("Invalid client_id")
|
||||||
};
|
};
|
||||||
|
|||||||
+160
-112
@@ -1,26 +1,28 @@
|
|||||||
use std::path::Path;
|
use std::{path::Path, sync::LazyLock, time::Duration};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use once_cell::sync::Lazy;
|
use rocket::{
|
||||||
use rocket::form::Form;
|
form::Form,
|
||||||
use rocket::fs::NamedFile;
|
fs::{NamedFile, TempFile},
|
||||||
use rocket::fs::TempFile;
|
serde::json::Json,
|
||||||
use rocket::serde::json::Json;
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
|
||||||
auth::{ClientIp, Headers, Host},
|
|
||||||
config::PathType,
|
|
||||||
db::{models::*, DbConn, DbPool},
|
|
||||||
util::{save_temp_file, NumberOrString},
|
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
|
auth::{ClientIp, Headers, Host, SendHeaders},
|
||||||
|
config::PathType,
|
||||||
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
|
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
|
||||||
|
},
|
||||||
|
util::{NumberOrString, save_temp_file},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
|
||||||
static ANON_PUSH_DEVICE: Lazy<Device> = Lazy::new(|| {
|
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
|
||||||
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
|
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
|
||||||
Device {
|
Device {
|
||||||
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
|
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
|
||||||
@@ -46,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
post_send,
|
post_send,
|
||||||
post_send_file,
|
post_send_file,
|
||||||
post_access,
|
post_access,
|
||||||
|
post_access_legacy,
|
||||||
post_access_file,
|
post_access_file,
|
||||||
|
post_access_file_legacy,
|
||||||
put_send,
|
put_send,
|
||||||
delete_send,
|
delete_send,
|
||||||
put_remove_password,
|
put_remove_password,
|
||||||
@@ -58,10 +62,10 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||||||
|
|
||||||
pub async fn purge_sends(pool: DbPool) {
|
pub async fn purge_sends(pool: DbPool) {
|
||||||
debug!("Purging sends");
|
debug!("Purging sends");
|
||||||
if let Ok(mut conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
Send::purge(&mut conn).await;
|
Send::purge(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging sends")
|
error!("Failed to get DB connection while purging sends");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +80,7 @@ pub struct SendData {
|
|||||||
deletion_date: DateTime<Utc>,
|
deletion_date: DateTime<Utc>,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
hide_email: Option<bool>,
|
hide_email: Option<bool>,
|
||||||
|
emails: Option<String>,
|
||||||
|
|
||||||
// Data field
|
// Data field
|
||||||
name: String,
|
name: String,
|
||||||
@@ -96,7 +101,7 @@ pub struct SendData {
|
|||||||
///
|
///
|
||||||
/// There is also a Vaultwarden-specific `sends_allowed` config setting that
|
/// There is also a Vaultwarden-specific `sends_allowed` config setting that
|
||||||
/// controls this policy globally.
|
/// controls this policy globally.
|
||||||
async fn enforce_disable_send_policy(headers: &Headers, conn: &mut DbConn) -> EmptyResult {
|
async fn enforce_disable_send_policy(headers: &Headers, conn: &DbConn) -> EmptyResult {
|
||||||
let user_id = &headers.user.uuid;
|
let user_id = &headers.user.uuid;
|
||||||
if !CONFIG.sends_allowed()
|
if !CONFIG.sends_allowed()
|
||||||
|| OrgPolicy::is_applicable_to_user(user_id, OrgPolicyType::DisableSend, None, conn).await
|
|| OrgPolicy::is_applicable_to_user(user_id, OrgPolicyType::DisableSend, None, conn).await
|
||||||
@@ -112,7 +117,7 @@ async fn enforce_disable_send_policy(headers: &Headers, conn: &mut DbConn) -> Em
|
|||||||
/// but is allowed to remove this option from an existing Send.
|
/// but is allowed to remove this option from an existing Send.
|
||||||
///
|
///
|
||||||
/// Ref: https://bitwarden.com/help/article/policies/#send-options
|
/// Ref: https://bitwarden.com/help/article/policies/#send-options
|
||||||
async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &mut DbConn) -> EmptyResult {
|
async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &DbConn) -> EmptyResult {
|
||||||
let user_id = &headers.user.uuid;
|
let user_id = &headers.user.uuid;
|
||||||
let hide_email = data.hide_email.unwrap_or(false);
|
let hide_email = data.hide_email.unwrap_or(false);
|
||||||
if hide_email && OrgPolicy::is_hide_email_disabled(user_id, conn).await {
|
if hide_email && OrgPolicy::is_hide_email_disabled(user_id, conn).await {
|
||||||
@@ -146,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if data.emails.is_some() {
|
||||||
|
err!("Sends with email verification is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
||||||
send.user_uuid = Some(user_id);
|
send.user_uuid = Some(user_id);
|
||||||
send.notes = data.notes;
|
send.notes = data.notes;
|
||||||
@@ -164,9 +173,9 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/sends")]
|
#[get("/sends")]
|
||||||
async fn get_sends(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let sends = Send::find_by_user(&headers.user.uuid, &mut conn);
|
let sends = Send::find_by_user(&headers.user.uuid, &conn);
|
||||||
let sends_json: Vec<Value> = sends.await.iter().map(|s| s.to_json()).collect();
|
let sends_json: Vec<Value> = sends.await.iter().map(Send::to_json).collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"data": sends_json,
|
"data": sends_json,
|
||||||
@@ -176,32 +185,33 @@ async fn get_sends(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/sends/<send_id>")]
|
#[get("/sends/<send_id>")]
|
||||||
async fn get_send(send_id: SendId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await {
|
if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await {
|
||||||
Some(send) => Ok(Json(send.to_json())),
|
Ok(Json(send.to_json()))
|
||||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
} else {
|
||||||
|
err!("Send not found", "Invalid send uuid or does not belong to user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/sends", data = "<data>")]
|
#[post("/sends", data = "<data>")]
|
||||||
async fn post_send(data: Json<SendData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
|
|
||||||
let data: SendData = data.into_inner();
|
let data: SendData = data.into_inner();
|
||||||
enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?;
|
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||||
|
|
||||||
if data.r#type == SendType::File as i32 {
|
if data.r#type == SendType::File as i32 {
|
||||||
err!("File sends should use /api/sends/file")
|
err!("File sends should use /api/sends/file")
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut send = create_send(data, headers.user.uuid)?;
|
let mut send = create_send(data, headers.user.uuid)?;
|
||||||
send.save(&mut conn).await?;
|
send.save(&conn).await?;
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendCreate,
|
UpdateType::SyncSendCreate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -225,8 +235,8 @@ struct UploadDataV2<'f> {
|
|||||||
// 2025: This endpoint doesn't seem to exists anymore in the latest version
|
// 2025: This endpoint doesn't seem to exists anymore in the latest version
|
||||||
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
|
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
|
||||||
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
|
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
|
||||||
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
|
|
||||||
let UploadData {
|
let UploadData {
|
||||||
model,
|
model,
|
||||||
@@ -241,12 +251,12 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
|
|||||||
err!("Send size can't be negative")
|
err!("Send size can't be negative")
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce_disable_hide_email_policy(&model, &headers, &mut conn).await?;
|
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
|
||||||
|
|
||||||
let size_limit = match CONFIG.user_send_limit() {
|
let size_limit = match CONFIG.user_send_limit() {
|
||||||
Some(0) => err!("File uploads are disabled"),
|
Some(0) => err!("File uploads are disabled"),
|
||||||
Some(limit_kb) => {
|
Some(limit_kb) => {
|
||||||
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &mut conn).await else {
|
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
|
||||||
err!("Existing sends overflow")
|
err!("Existing sends overflow")
|
||||||
};
|
};
|
||||||
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
|
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
|
||||||
@@ -271,7 +281,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
|
|||||||
|
|
||||||
let file_id = crate::crypto::generate_send_file_id();
|
let file_id = crate::crypto::generate_send_file_id();
|
||||||
|
|
||||||
save_temp_file(PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
|
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
|
||||||
|
|
||||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||||
if let Some(o) = data_value.as_object_mut() {
|
if let Some(o) = data_value.as_object_mut() {
|
||||||
@@ -282,13 +292,13 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
|
|||||||
send.data = serde_json::to_string(&data_value)?;
|
send.data = serde_json::to_string(&data_value)?;
|
||||||
|
|
||||||
// Save the changes in the database
|
// Save the changes in the database
|
||||||
send.save(&mut conn).await?;
|
send.save(&conn).await?;
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendCreate,
|
UpdateType::SyncSendCreate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -297,8 +307,8 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
|
|||||||
|
|
||||||
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
|
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
|
||||||
#[post("/sends/file/v2", data = "<data>")]
|
#[post("/sends/file/v2", data = "<data>")]
|
||||||
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
@@ -306,11 +316,12 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbC
|
|||||||
err!("Send content is not a file");
|
err!("Send content is not a file");
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?;
|
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||||
|
|
||||||
let file_length = match &data.file_length {
|
let file_length = if let Some(m) = &data.file_length {
|
||||||
Some(m) => m.into_i64()?,
|
m.into_i64()?
|
||||||
_ => err!("Invalid send length"),
|
} else {
|
||||||
|
err!("Invalid send length")
|
||||||
};
|
};
|
||||||
if file_length < 0 {
|
if file_length < 0 {
|
||||||
err!("Send size can't be negative")
|
err!("Send size can't be negative")
|
||||||
@@ -319,7 +330,7 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbC
|
|||||||
let size_limit = match CONFIG.user_send_limit() {
|
let size_limit = match CONFIG.user_send_limit() {
|
||||||
Some(0) => err!("File uploads are disabled"),
|
Some(0) => err!("File uploads are disabled"),
|
||||||
Some(limit_kb) => {
|
Some(limit_kb) => {
|
||||||
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &mut conn).await else {
|
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
|
||||||
err!("Existing sends overflow")
|
err!("Existing sends overflow")
|
||||||
};
|
};
|
||||||
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
|
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
|
||||||
@@ -348,7 +359,7 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbC
|
|||||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
||||||
}
|
}
|
||||||
send.data = serde_json::to_string(&data_value)?;
|
send.data = serde_json::to_string(&data_value)?;
|
||||||
send.save(&mut conn).await?;
|
send.save(&conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"fileUploadType": 0, // 0 == Direct | 1 == Azure
|
"fileUploadType": 0, // 0 == Direct | 1 == Azure
|
||||||
@@ -367,20 +378,20 @@ pub struct SendFileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
|
||||||
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
|
||||||
async fn post_send_file_v2_data(
|
async fn post_send_file_v2_data(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
data: Form<UploadDataV2<'_>>,
|
data: Form<UploadDataV2<'_>>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
|
|
||||||
let data = data.into_inner();
|
let data = data.into_inner();
|
||||||
|
|
||||||
let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else {
|
let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.")
|
err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -423,49 +434,60 @@ async fn post_send_file_v2_data(
|
|||||||
|
|
||||||
let file_path = format!("{send_id}/{file_id}");
|
let file_path = format!("{send_id}/{file_id}");
|
||||||
|
|
||||||
save_temp_file(PathType::Sends, &file_path, data.data, false).await?;
|
save_temp_file(&PathType::Sends, &file_path, data.data, false).await?;
|
||||||
|
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendCreate,
|
UpdateType::SyncSendCreate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[post("/sends/access")]
|
||||||
|
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SendAccessData {
|
pub struct SendAccessData {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
#[post("/sends/access/<access_id>", data = "<data>")]
|
#[post("/sends/access/<access_id>", data = "<data>")]
|
||||||
async fn post_access(
|
async fn post_access_legacy(
|
||||||
access_id: &str,
|
access_id: &str,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let Some(mut send) = Send::find_by_access_id(access_id, &mut conn).await else {
|
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)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
|
{
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
err_code!(SEND_INACCESSIBLE_MSG, 404);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
if let Some(expiration) = send.expiration_date
|
||||||
if Utc::now().naive_utc() >= expiration {
|
&& Utc::now().naive_utc() >= expiration
|
||||||
|
{
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
if Utc::now().naive_utc() >= send.deletion_date {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
@@ -485,47 +507,71 @@ async fn post_access(
|
|||||||
|
|
||||||
// Files are incremented during the download
|
// Files are incremented during the download
|
||||||
if send.atype == SendType::Text as i32 {
|
if send.atype == SendType::Text as i32 {
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
send.save(&mut conn).await?;
|
process_access(send, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&ANON_PUSH_DEVICE,
|
&ANON_PUSH_DEVICE,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(send.to_json_access(&mut conn).await))
|
Ok(Json(send.to_json_access(&conn).await))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
#[post("/sends/access/file/<file_id>", rank = 1)]
|
||||||
async fn post_access_file(
|
async fn post_access_file(
|
||||||
|
file_id: SendFileId,
|
||||||
|
headers: SendHeaders,
|
||||||
|
host: Host,
|
||||||
|
conn: DbConn,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
};
|
||||||
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy since web-2026.6.0
|
||||||
|
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
||||||
|
async fn post_access_file_legacy(
|
||||||
send_id: SendId,
|
send_id: SendId,
|
||||||
file_id: SendFileId,
|
file_id: SendFileId,
|
||||||
data: Json<SendAccessData>,
|
data: Json<SendAccessData>,
|
||||||
host: Host,
|
host: Host,
|
||||||
mut conn: DbConn,
|
conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let Some(mut send) = Send::find_by_uuid(&send_id, &mut conn).await else {
|
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)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(max_access_count) = send.max_access_count {
|
if let Some(max_access_count) = send.max_access_count
|
||||||
if send.access_count >= max_access_count {
|
&& send.access_count >= max_access_count
|
||||||
|
{
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(expiration) = send.expiration_date {
|
if let Some(expiration) = send.expiration_date
|
||||||
if Utc::now().naive_utc() >= expiration {
|
&& Utc::now().naive_utc() >= expiration
|
||||||
|
{
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if Utc::now().naive_utc() >= send.deletion_date {
|
if Utc::now().naive_utc() >= send.deletion_date {
|
||||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
@@ -543,67 +589,69 @@ async fn post_access_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send.access_count += 1;
|
if !send.register_access(&conn).await? {
|
||||||
|
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||||
|
}
|
||||||
|
|
||||||
send.save(&mut conn).await?;
|
process_access_file(send, file_id, host, conn, nt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&ANON_PUSH_DEVICE,
|
&ANON_PUSH_DEVICE,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"object": "send-fileDownload",
|
"object": "send-fileDownload",
|
||||||
"id": file_id,
|
"id": file_id,
|
||||||
"url": download_url(&host, &send_id, &file_id).await?,
|
"url": download_url(&host, &send.uuid, &file_id).await?,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::Sends)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
|
||||||
|
|
||||||
if operator.info().scheme() == opendal::Scheme::Fs {
|
if crate::storage::is_fs_operator(&operator) {
|
||||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||||
let token = crate::auth::encode_jwt(&token_claims);
|
let token = crate::auth::encode_jwt(&token_claims);
|
||||||
|
|
||||||
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host))
|
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", host.host))
|
||||||
} else {
|
} else {
|
||||||
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_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>")]
|
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
if let Ok(claims) = crate::auth::decode_send(t)
|
||||||
if claims.sub == format!("{send_id}/{file_id}") {
|
&& claims.sub == format!("{send_id}/{file_id}")
|
||||||
|
{
|
||||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/sends/<send_id>", data = "<data>")]
|
#[put("/sends/<send_id>", data = "<data>")]
|
||||||
async fn put_send(
|
async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
send_id: SendId,
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
data: Json<SendData>,
|
|
||||||
headers: Headers,
|
|
||||||
mut conn: DbConn,
|
|
||||||
nt: Notify<'_>,
|
|
||||||
) -> JsonResult {
|
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
|
||||||
|
|
||||||
let data: SendData = data.into_inner();
|
let data: SendData = data.into_inner();
|
||||||
enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?;
|
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else {
|
let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
||||||
};
|
};
|
||||||
|
|
||||||
update_send_from_data(&mut send, data, &headers, &mut conn, &nt, UpdateType::SyncSendUpdate).await?;
|
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()))
|
Ok(Json(send.to_json()))
|
||||||
}
|
}
|
||||||
@@ -612,7 +660,7 @@ pub async fn update_send_from_data(
|
|||||||
send: &mut Send,
|
send: &mut Send,
|
||||||
data: SendData,
|
data: SendData,
|
||||||
headers: &Headers,
|
headers: &Headers,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
@@ -667,18 +715,18 @@ pub async fn update_send_from_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/sends/<send_id>")]
|
#[delete("/sends/<send_id>")]
|
||||||
async fn delete_send(send_id: SendId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_send(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||||
let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else {
|
let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
||||||
};
|
};
|
||||||
|
|
||||||
send.delete(&mut conn).await?;
|
send.delete(&conn).await?;
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendDelete,
|
UpdateType::SyncSendDelete,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -686,21 +734,21 @@ async fn delete_send(send_id: SendId, headers: Headers, mut conn: DbConn, nt: No
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/sends/<send_id>/remove-password")]
|
#[put("/sends/<send_id>/remove-password")]
|
||||||
async fn put_remove_password(send_id: SendId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
enforce_disable_send_policy(&headers, &conn).await?;
|
||||||
|
|
||||||
let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await else {
|
let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else {
|
||||||
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
||||||
};
|
};
|
||||||
|
|
||||||
send.set_password(None);
|
send.set_password(None);
|
||||||
send.save(&mut conn).await?;
|
send.save(&conn).await?;
|
||||||
nt.send_send_update(
|
nt.send_send_update(
|
||||||
UpdateType::SyncSendUpdate,
|
UpdateType::SyncSendUpdate,
|
||||||
&send,
|
&send,
|
||||||
&send.update_users_revision(&mut conn).await,
|
&send.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
&mut conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData},
|
api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code},
|
||||||
auth::{ClientIp, Headers},
|
auth::{ClientIp, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
};
|
};
|
||||||
@@ -20,18 +19,18 @@ pub fn routes() -> Vec<Route> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||||
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn generate_authenticator(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let type_ = TwoFactorType::Authenticator as i32;
|
let type_ = TwoFactorType::Authenticator as i32;
|
||||||
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await;
|
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await;
|
||||||
|
|
||||||
let (enabled, key) = match twofactor {
|
let (enabled, key) = match twofactor {
|
||||||
Some(tf) => (true, tf.data),
|
Some(tf) => (true, tf.data),
|
||||||
_ => (false, crypto::encode_random_bytes::<20>(BASE32)),
|
_ => (false, crypto::encode_random_bytes::<20>(&BASE32)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
// Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all.
|
||||||
@@ -55,7 +54,7 @@ struct EnableAuthenticatorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/authenticator", data = "<data>")]
|
#[post("/two-factor/authenticator", data = "<data>")]
|
||||||
async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: EnableAuthenticatorData = data.into_inner();
|
let data: EnableAuthenticatorData = data.into_inner();
|
||||||
let key = data.key;
|
let key = data.key;
|
||||||
let token = data.token.into_string();
|
let token = data.token.into_string();
|
||||||
@@ -66,13 +65,14 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
master_password_hash: data.master_password_hash,
|
master_password_hash: data.master_password_hash,
|
||||||
otp: data.otp,
|
otp: data.otp,
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Validate key as base32 and 20 bytes length
|
// Validate key as base32 and 20 bytes length
|
||||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
let decoded_key: Vec<u8> = if let Ok(decoded) = BASE32.decode(key.as_bytes()) {
|
||||||
Ok(decoded) => decoded,
|
decoded
|
||||||
_ => err!("Invalid totp secret"),
|
} else {
|
||||||
|
err!("Invalid totp secret")
|
||||||
};
|
};
|
||||||
|
|
||||||
if decoded_key.len() != 20 {
|
if decoded_key.len() != 20 {
|
||||||
@@ -80,11 +80,11 @@ async fn activate_authenticator(data: Json<EnableAuthenticatorData>, headers: He
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the token provided with the key, and save new twofactor
|
// Validate the token provided with the key, and save new twofactor
|
||||||
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &mut conn).await?;
|
validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &mut conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -103,7 +103,7 @@ pub async fn validate_totp_code_str(
|
|||||||
totp_code: &str,
|
totp_code: &str,
|
||||||
secret: &str,
|
secret: &str,
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
if !totp_code.chars().all(char::is_numeric) {
|
if !totp_code.chars().all(char::is_numeric) {
|
||||||
err!("TOTP code is not a number");
|
err!("TOTP code is not a number");
|
||||||
@@ -117,9 +117,9 @@ pub async fn validate_totp_code(
|
|||||||
totp_code: &str,
|
totp_code: &str,
|
||||||
secret: &str,
|
secret: &str,
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
use totp_lite::{totp_custom, Sha1};
|
use totp_lite::{Sha1, totp_custom};
|
||||||
|
|
||||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||||
err!("Invalid TOTP secret")
|
err!("Invalid TOTP secret")
|
||||||
@@ -128,7 +128,7 @@ pub async fn validate_totp_code(
|
|||||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||||
{
|
{
|
||||||
Some(tf) => tf,
|
Some(tf) => tf,
|
||||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
|
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The amount of steps back and forward in time
|
// The amount of steps back and forward in time
|
||||||
@@ -145,7 +145,7 @@ pub async fn validate_totp_code(
|
|||||||
|
|
||||||
// We need to calculate the time offsite and cast it as an u64.
|
// We need to calculate the time offsite and cast it as an u64.
|
||||||
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
// Since we only have times into the future and the totp generator needs an u64 instead of the default i64.
|
||||||
let time = (current_timestamp + step * 30i64) as u64;
|
let time: u64 = (current_timestamp + step * 30i64).cast_unsigned();
|
||||||
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
let generated = totp_custom::<Sha1>(30, 6, &decoded_secret, time);
|
||||||
|
|
||||||
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
// Check the given code equals the generated and if the time_step is larger then the one last used.
|
||||||
@@ -189,7 +189,7 @@ struct DisableAuthenticatorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/authenticator", data = "<data>")]
|
#[delete("/two-factor/authenticator", data = "<data>")]
|
||||||
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
let type_ = data.r#type.into_i32()?;
|
let type_ = data.r#type.into_i32()?;
|
||||||
|
|
||||||
@@ -197,24 +197,18 @@ async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: He
|
|||||||
err!("Invalid password");
|
err!("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
if twofactor.data == data.key {
|
if twofactor.data == data.key {
|
||||||
twofactor.delete(&mut conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
log_user_event(
|
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
EventType::UserDisabled2fa as i32,
|
|
||||||
&user.uuid,
|
|
||||||
headers.device.atype,
|
|
||||||
&headers.ip.ip,
|
|
||||||
&mut conn,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if TwoFactor::find_by_user(&user.uuid, &mut conn).await.is_empty() {
|
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
||||||
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await?;
|
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::BASE64;
|
use data_encoding::BASE64;
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult,
|
ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event,
|
||||||
PasswordOrOtpData,
|
core::two_factor::generate_recover_code,
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -82,8 +81,7 @@ enum DuoStatus {
|
|||||||
impl DuoStatus {
|
impl DuoStatus {
|
||||||
fn data(self) -> Option<DuoData> {
|
fn data(self) -> Option<DuoData> {
|
||||||
match self {
|
match self {
|
||||||
DuoStatus::Global(data) => Some(data),
|
DuoStatus::Global(data) | DuoStatus::User(data) => Some(data),
|
||||||
DuoStatus::User(data) => Some(data),
|
|
||||||
DuoStatus::Disabled(_) => None,
|
DuoStatus::Disabled(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,13 +90,13 @@ impl DuoStatus {
|
|||||||
const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>";
|
const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>";
|
||||||
|
|
||||||
#[post("/two-factor/get-duo", data = "<data>")]
|
#[post("/two-factor/get-duo", data = "<data>")]
|
||||||
async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let data = get_user_duo_data(&user.uuid, &mut conn).await;
|
let data = get_user_duo_data(&user.uuid, &conn).await;
|
||||||
|
|
||||||
let (enabled, data) = match data {
|
let (enabled, data) = match data {
|
||||||
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
|
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
|
||||||
@@ -158,7 +156,7 @@ fn check_duo_fields_custom(data: &EnableDuoData) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/duo", data = "<data>")]
|
#[post("/two-factor/duo", data = "<data>")]
|
||||||
async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: EnableDuoData = data.into_inner();
|
let data: EnableDuoData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -166,7 +164,7 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, mut conn: DbC
|
|||||||
master_password_hash: data.master_password_hash.clone(),
|
master_password_hash: data.master_password_hash.clone(),
|
||||||
otp: data.otp.clone(),
|
otp: data.otp.clone(),
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (data, data_str) = if check_duo_fields_custom(&data) {
|
let (data, data_str) = if check_duo_fields_custom(&data) {
|
||||||
@@ -180,11 +178,11 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, mut conn: DbC
|
|||||||
|
|
||||||
let type_ = TwoFactorType::Duo;
|
let type_ = TwoFactorType::Duo;
|
||||||
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str);
|
||||||
twofactor.save(&mut conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &mut conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -201,14 +199,14 @@ async fn activate_duo_put(data: Json<EnableDuoData>, headers: Headers, conn: DbC
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
|
||||||
use reqwest::{header, Method};
|
use reqwest::{Method, header};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
// https://duo.com/docs/authapi#api-details
|
// https://duo.com/docs/authapi#api-details
|
||||||
let url = format!("https://{}{path}", &data.host);
|
let url = format!("https://{}{path}", data.host);
|
||||||
let date = Utc::now().to_rfc2822();
|
let dt = Utc::now().to_rfc2822();
|
||||||
let username = &data.ik;
|
let username = &data.ik;
|
||||||
let fields = [&date, method, &data.host, path, params];
|
let fields = [&dt, method, &data.host, path, params];
|
||||||
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
let password = crypto::hmac_sign(&data.sk, &fields.join("\n"));
|
||||||
|
|
||||||
let m = Method::from_str(method).unwrap_or_default();
|
let m = Method::from_str(method).unwrap_or_default();
|
||||||
@@ -216,7 +214,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
|||||||
make_http_request(m, &url)?
|
make_http_request(m, &url)?
|
||||||
.basic_auth(username, Some(password))
|
.basic_auth(username, Some(password))
|
||||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||||
.header(header::DATE, date)
|
.header(header::DATE, dt)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
@@ -231,7 +229,7 @@ const AUTH_PREFIX: &str = "AUTH";
|
|||||||
const DUO_PREFIX: &str = "TX";
|
const DUO_PREFIX: &str = "TX";
|
||||||
const APP_PREFIX: &str = "APP";
|
const APP_PREFIX: &str = "APP";
|
||||||
|
|
||||||
async fn get_user_duo_data(user_id: &UserId, conn: &mut DbConn) -> DuoStatus {
|
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
|
||||||
let type_ = TwoFactorType::Duo as i32;
|
let type_ = TwoFactorType::Duo as i32;
|
||||||
|
|
||||||
// If the user doesn't have an entry, disabled
|
// If the user doesn't have an entry, disabled
|
||||||
@@ -254,7 +252,7 @@ async fn get_user_duo_data(user_id: &UserId, conn: &mut DbConn) -> DuoStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// let (ik, sk, ak, host) = get_duo_keys();
|
// let (ik, sk, ak, host) = get_duo_keys();
|
||||||
pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiResult<(String, String, String, String)> {
|
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
|
||||||
let data = match User::find_by_mail(email, conn).await {
|
let data = match User::find_by_mail(email, conn).await {
|
||||||
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
|
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
|
||||||
_ => DuoData::global(),
|
_ => DuoData::global(),
|
||||||
@@ -264,7 +262,7 @@ pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiRes
|
|||||||
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
|
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_duo_signature(email: &str, conn: &mut DbConn) -> ApiResult<(String, String)> {
|
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
|
|
||||||
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
|
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
|
||||||
@@ -282,7 +280,7 @@ fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64
|
|||||||
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
|
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_duo_login(email: &str, response: &str, conn: &mut DbConn) -> EmptyResult {
|
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
|
||||||
let split: Vec<&str> = response.split(':').collect();
|
let split: Vec<&str> = response.split(':').collect();
|
||||||
if split.len() != 2 {
|
if split.len() != 2 {
|
||||||
err!(
|
err!(
|
||||||
@@ -356,9 +354,10 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
|||||||
err!("Invalid ikey")
|
err!("Invalid ikey")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expire: i64 = match expire.parse() {
|
let expire: i64 = if let Ok(e) = expire.parse() {
|
||||||
Ok(e) => e,
|
e
|
||||||
Err(_) => err!("Invalid expire time"),
|
} else {
|
||||||
|
err!("Invalid expire time")
|
||||||
};
|
};
|
||||||
|
|
||||||
if time >= expire {
|
if time >= expire {
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use reqwest::{header, StatusCode};
|
use reqwest::{StatusCode, header};
|
||||||
use ring::digest::{digest, Digest, SHA512_256};
|
use ring::digest::{Digest, SHA512_256, digest};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
CONFIG,
|
||||||
|
api::{EmptyResult, core::two_factor::duo::get_duo_keys_email},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{DeviceId, EventType, TwoFactorDuoContext},
|
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
|
models::{DeviceId, EventType, TwoFactorDuoContext},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
||||||
// built in to the Bitwarden clients.
|
// built in to the Bitwarden clients.
|
||||||
@@ -124,7 +125,7 @@ impl DuoClient {
|
|||||||
ClientAssertion {
|
ClientAssertion {
|
||||||
iss: self.client_id.clone(),
|
iss: self.client_id.clone(),
|
||||||
sub: self.client_id.clone(),
|
sub: self.client_id.clone(),
|
||||||
aud: url.to_string(),
|
aud: url.to_owned(),
|
||||||
exp: now + JWT_VALIDITY_SECS,
|
exp: now + JWT_VALIDITY_SECS,
|
||||||
jti: jwt_id,
|
jti: jwt_id,
|
||||||
iat: now,
|
iat: now,
|
||||||
@@ -302,7 +303,7 @@ impl DuoClient {
|
|||||||
|
|
||||||
if !(matching_nonces && matching_usernames) {
|
if !(matching_nonces && matching_usernames) {
|
||||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -317,7 +318,7 @@ struct DuoAuthContext {
|
|||||||
|
|
||||||
// Given a state string, retrieve the associated Duo auth context and
|
// Given a state string, retrieve the associated Duo auth context and
|
||||||
// delete the retrieved state from the database.
|
// delete the retrieved state from the database.
|
||||||
async fn extract_context(state: &str, conn: &mut DbConn) -> Option<DuoAuthContext> {
|
async fn extract_context(state: &str, conn: &DbConn) -> Option<DuoAuthContext> {
|
||||||
let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await {
|
let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return None,
|
None => return None,
|
||||||
@@ -344,10 +345,10 @@ async fn extract_context(state: &str, conn: &mut DbConn) -> Option<DuoAuthContex
|
|||||||
// Task to clean up expired Duo authentication contexts that may have accumulated in the database.
|
// Task to clean up expired Duo authentication contexts that may have accumulated in the database.
|
||||||
pub async fn purge_duo_contexts(pool: DbPool) {
|
pub async fn purge_duo_contexts(pool: DbPool) {
|
||||||
debug!("Purging Duo authentication contexts");
|
debug!("Purging Duo authentication contexts");
|
||||||
if let Ok(mut conn) = pool.get().await {
|
if let Ok(conn) = pool.get().await {
|
||||||
TwoFactorDuoContext::purge_expired_duo_contexts(&mut conn).await;
|
TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await;
|
||||||
} else {
|
} else {
|
||||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
error!("Failed to get DB connection while purging expired Duo authentications");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +381,7 @@ pub async fn get_duo_auth_url(
|
|||||||
email: &str,
|
email: &str,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
device_identifier: &DeviceId,
|
device_identifier: &DeviceId,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
||||||
|
|
||||||
@@ -394,7 +395,7 @@ pub async fn get_duo_auth_url(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
// Generate random OAuth2 state and OIDC Nonce
|
// Generate random OAuth2 state and OIDC Nonce
|
||||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||||
@@ -418,7 +419,7 @@ pub async fn validate_duo_login(
|
|||||||
two_factor_token: &str,
|
two_factor_token: &str,
|
||||||
client_id: &str,
|
client_id: &str,
|
||||||
device_identifier: &DeviceId,
|
device_identifier: &DeviceId,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
// Result supplied to us by clients in the form "<authz code>|<state>"
|
// Result supplied to us by clients in the form "<authz code>|<state>"
|
||||||
let split: Vec<&str> = two_factor_token.split('|').collect();
|
let split: Vec<&str> = two_factor_token.split('|').collect();
|
||||||
@@ -438,16 +439,13 @@ pub async fn validate_duo_login(
|
|||||||
|
|
||||||
// Get the context by the state reported by the client. If we don't have one,
|
// Get the context by the state reported by the client. If we don't have one,
|
||||||
// it means the context is either missing or expired.
|
// it means the context is either missing or expired.
|
||||||
let ctx = match extract_context(state, conn).await {
|
let Some(ctx) = extract_context(state, conn).await else {
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
err!(
|
err!(
|
||||||
"Error validating duo authentication",
|
"Error validating duo authentication",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn2fa
|
event: EventType::UserFailedLogIn2fa
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Context validation steps
|
// Context validation steps
|
||||||
@@ -476,13 +474,13 @@ pub async fn validate_duo_login(
|
|||||||
match client.health_check().await {
|
match client.health_check().await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
};
|
}
|
||||||
|
|
||||||
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes());
|
||||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||||
|
|
||||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
err!(
|
err!(
|
||||||
"Error validating duo authentication",
|
"Error validating duo authentication",
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::{ClientHeaders, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -24,41 +24,89 @@ pub fn routes() -> Vec<Route> {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct SendEmailLoginData {
|
struct SendEmailLoginData {
|
||||||
device_identifier: DeviceId,
|
#[serde(alias = "DeviceIdentifier")]
|
||||||
|
device_identifier: Option<DeviceId>,
|
||||||
#[allow(unused)]
|
|
||||||
#[serde(alias = "Email")]
|
#[serde(alias = "Email")]
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
|
|
||||||
#[allow(unused)]
|
|
||||||
#[serde(alias = "MasterPasswordHash")]
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
master_password_hash: Option<String>,
|
master_password_hash: Option<String>,
|
||||||
|
auth_request_id: Option<AuthRequestId>,
|
||||||
|
auth_request_access_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User is trying to login and wants to use email 2FA.
|
/// User is trying to login and wants to use email 2FA.
|
||||||
/// Does not require Bearer token
|
/// Does not require Bearer token
|
||||||
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
#[post("/two-factor/send-email-login", data = "<data>")] // JsonResult
|
||||||
async fn send_email_login(data: Json<SendEmailLoginData>, mut conn: DbConn) -> EmptyResult {
|
async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult {
|
||||||
let data: SendEmailLoginData = data.into_inner();
|
let data: SendEmailLoginData = data.into_inner();
|
||||||
|
|
||||||
use crate::db::models::User;
|
|
||||||
|
|
||||||
// Get the user
|
|
||||||
let Some(user) = User::find_by_device_id(&data.device_identifier, &mut conn).await else {
|
|
||||||
err!("Cannot find user. Try again.")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !CONFIG._enable_email_2fa() {
|
if !CONFIG._enable_email_2fa() {
|
||||||
err!("Email 2FA is disabled")
|
err!("Email 2FA is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
send_token(&user.uuid, &mut conn).await?;
|
// Ratelimit the login
|
||||||
|
crate::ratelimit::check_limit_login(&client_headers.ip.ip)?;
|
||||||
|
|
||||||
Ok(())
|
// Get the user
|
||||||
|
let email = match &data.email {
|
||||||
|
Some(email) if !email.is_empty() => Some(email),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let master_password_hash = match &data.master_password_hash {
|
||||||
|
Some(password_hash) if !password_hash.is_empty() => Some(password_hash),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let auth_request_id = match &data.auth_request_id {
|
||||||
|
Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = if let Some(email) = email {
|
||||||
|
let Some(user) = User::find_by_mail(email, &conn).await else {
|
||||||
|
err!("Username or password is incorrect. Try again.")
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(master_password_hash) = master_password_hash {
|
||||||
|
// Check password
|
||||||
|
if !user.check_valid_password(master_password_hash) {
|
||||||
|
err!("Username or password is incorrect. Try again.")
|
||||||
|
}
|
||||||
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
|
};
|
||||||
|
let Some(code) = &data.auth_request_access_code else {
|
||||||
|
err!("no auth request access code")
|
||||||
|
};
|
||||||
|
|
||||||
|
if auth_request.device_type != client_headers.device_type
|
||||||
|
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||||
|
|| !auth_request.check_access_code(code)
|
||||||
|
{
|
||||||
|
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err!("No password hash has been submitted.")
|
||||||
|
}
|
||||||
|
|
||||||
|
user
|
||||||
|
} else {
|
||||||
|
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
|
/// Generate the token, save the data for later verification and send email to user
|
||||||
pub async fn send_token(user_id: &UserId, conn: &mut DbConn) -> EmptyResult {
|
pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
let type_ = TwoFactorType::Email as i32;
|
let type_ = TwoFactorType::Email as i32;
|
||||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
|
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
|
||||||
|
|
||||||
@@ -76,14 +124,14 @@ pub async fn send_token(user_id: &UserId, conn: &mut DbConn) -> EmptyResult {
|
|||||||
|
|
||||||
/// When user clicks on Manage email 2FA show the user the related information
|
/// When user clicks on Manage email 2FA show the user the related information
|
||||||
#[post("/two-factor/get-email", data = "<data>")]
|
#[post("/two-factor/get-email", data = "<data>")]
|
||||||
async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_email(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let (enabled, mfa_email) =
|
let (enabled, mfa_email) =
|
||||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await {
|
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await {
|
||||||
Some(x) => {
|
Some(x) => {
|
||||||
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
let twofactor_data = EmailTokenData::from_json(&x.data)?;
|
||||||
(true, json!(twofactor_data.email))
|
(true, json!(twofactor_data.email))
|
||||||
@@ -109,7 +157,7 @@ struct SendEmailData {
|
|||||||
|
|
||||||
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
|
||||||
#[post("/two-factor/send-email", data = "<data>")]
|
#[post("/two-factor/send-email", data = "<data>")]
|
||||||
async fn send_email(data: Json<SendEmailData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: SendEmailData = data.into_inner();
|
let data: SendEmailData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
@@ -117,7 +165,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, mut conn: DbCon
|
|||||||
master_password_hash: data.master_password_hash,
|
master_password_hash: data.master_password_hash,
|
||||||
otp: data.otp,
|
otp: data.otp,
|
||||||
}
|
}
|
||||||
.validate(&user, false, &mut conn)
|
.validate(&user, false, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !CONFIG._enable_email_2fa() {
|
if !CONFIG._enable_email_2fa() {
|
||||||
@@ -126,8 +174,8 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, mut conn: DbCon
|
|||||||
|
|
||||||
let type_ = TwoFactorType::Email as i32;
|
let type_ = TwoFactorType::Email as i32;
|
||||||
|
|
||||||
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
|
if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
tf.delete(&mut conn).await?;
|
tf.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||||
@@ -135,7 +183,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, mut conn: DbCon
|
|||||||
|
|
||||||
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
||||||
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
|
||||||
twofactor.save(&mut conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
|
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?;
|
||||||
|
|
||||||
@@ -153,7 +201,7 @@ struct EmailData {
|
|||||||
|
|
||||||
/// Verify email belongs to user and can be used for 2FA email codes.
|
/// Verify email belongs to user and can be used for 2FA email codes.
|
||||||
#[put("/two-factor/email", data = "<data>")]
|
#[put("/two-factor/email", data = "<data>")]
|
||||||
async fn email(data: Json<EmailData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn email(data: Json<EmailData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: EmailData = data.into_inner();
|
let data: EmailData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -162,12 +210,12 @@ async fn email(data: Json<EmailData>, headers: Headers, mut conn: DbConn) -> Jso
|
|||||||
master_password_hash: data.master_password_hash,
|
master_password_hash: data.master_password_hash,
|
||||||
otp: data.otp,
|
otp: data.otp,
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
|
||||||
let mut twofactor =
|
let mut twofactor =
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await.map_res("Two factor not found")?;
|
TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?;
|
||||||
|
|
||||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||||
|
|
||||||
@@ -182,11 +230,11 @@ async fn email(data: Json<EmailData>, headers: Headers, mut conn: DbConn) -> Jso
|
|||||||
email_data.reset_token();
|
email_data.reset_token();
|
||||||
twofactor.atype = TwoFactorType::Email as i32;
|
twofactor.atype = TwoFactorType::Email as i32;
|
||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(&mut conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &mut conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"email": email_data.email,
|
"email": email_data.email,
|
||||||
@@ -201,7 +249,7 @@ pub async fn validate_email_code_str(
|
|||||||
token: &str,
|
token: &str,
|
||||||
data: &str,
|
data: &str,
|
||||||
ip: &std::net::IpAddr,
|
ip: &std::net::IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let mut email_data = EmailTokenData::from_json(data)?;
|
let mut email_data = EmailTokenData::from_json(data)?;
|
||||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
|
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn)
|
||||||
@@ -236,9 +284,9 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
@@ -285,7 +333,7 @@ impl EmailTokenData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_attempt(&mut self) {
|
pub fn add_attempt(&mut self) {
|
||||||
self.attempts += 1;
|
self.attempts = self.attempts.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json(&self) -> String {
|
pub fn to_json(&self) -> String {
|
||||||
@@ -294,14 +342,15 @@ impl EmailTokenData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
} else {
|
||||||
|
err!("Could not decode EmailTokenData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn activate_email_2fa(user: &User, conn: &mut DbConn) -> EmptyResult {
|
pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
|
||||||
if user.verified_at.is_none() {
|
if user.verified_at.is_none() {
|
||||||
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
|
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
|
||||||
}
|
}
|
||||||
@@ -314,24 +363,23 @@ pub async fn activate_email_2fa(user: &User, conn: &mut DbConn) -> EmptyResult {
|
|||||||
pub fn obscure_email(email: &str) -> String {
|
pub fn obscure_email(email: &str) -> String {
|
||||||
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
let split: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
|
|
||||||
let mut name = split[1].to_string();
|
let mut name = split[1].to_owned();
|
||||||
let domain = &split[0];
|
let domain = &split[0];
|
||||||
|
|
||||||
let name_size = name.chars().count();
|
let name_size = name.chars().count();
|
||||||
|
|
||||||
let new_name = match name_size {
|
let new_name = if let 1..=3 = name_size {
|
||||||
1..=3 => "*".repeat(name_size),
|
"*".repeat(name_size)
|
||||||
_ => {
|
} else {
|
||||||
let stars = "*".repeat(name_size - 2);
|
let stars = "*".repeat(name_size - 2);
|
||||||
name.truncate(2);
|
name.truncate(2);
|
||||||
format!("{name}{stars}")
|
format!("{name}{stars}")
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
format!("{new_name}@{domain}")
|
format!("{new_name}@{domain}")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_and_activate_email_2fa(user_id: &UserId, conn: &mut DbConn) -> EmptyResult {
|
pub async fn find_and_activate_email_2fa(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
||||||
activate_email_2fa(&user, conn).await
|
activate_email_2fa(&user, conn).await
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
use chrono::{TimeDelta, Utc};
|
use chrono::{TimeDelta, Utc};
|
||||||
use data_encoding::BASE32;
|
use data_encoding::BASE32;
|
||||||
use rocket::serde::json::Json;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::Route;
|
use rocket::{Route, serde::json::Json};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_event, log_user_event},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_event, log_user_event},
|
||||||
},
|
},
|
||||||
auth::{ClientHeaders, Headers},
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{models::*, DbConn, DbPool},
|
db::{
|
||||||
|
DbConn, DbPool,
|
||||||
|
models::{
|
||||||
|
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
|
||||||
|
TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
mail,
|
mail,
|
||||||
util::NumberOrString,
|
util::NumberOrString,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod authenticator;
|
pub mod authenticator;
|
||||||
@@ -25,11 +32,46 @@ pub mod protected_actions;
|
|||||||
pub mod webauthn;
|
pub mod webauthn;
|
||||||
pub mod yubikey;
|
pub mod yubikey;
|
||||||
|
|
||||||
|
fn has_global_duo_credentials() -> bool {
|
||||||
|
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data: Option<&str>) -> bool {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DuoProviderData {
|
||||||
|
host: String,
|
||||||
|
ik: String,
|
||||||
|
sk: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
match provider_type {
|
||||||
|
TwoFactorType::Authenticator | 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> {
|
pub fn routes() -> Vec<Route> {
|
||||||
let mut routes = routes![
|
let mut routes = routes![
|
||||||
get_twofactor,
|
get_twofactor,
|
||||||
get_recover,
|
get_recover,
|
||||||
recover,
|
|
||||||
disable_twofactor,
|
disable_twofactor,
|
||||||
disable_twofactor_put,
|
disable_twofactor_put,
|
||||||
get_device_verification_settings,
|
get_device_verification_settings,
|
||||||
@@ -46,9 +88,15 @@ pub fn routes() -> Vec<Route> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/two-factor")]
|
#[get("/two-factor")]
|
||||||
async fn get_twofactor(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &mut conn).await;
|
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
|
||||||
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
|
let twofactors_json: Vec<Value> = twofactors
|
||||||
|
.iter()
|
||||||
|
.filter_map(|tf| {
|
||||||
|
let provider_type = TwoFactorType::from_i32(tf.atype)?;
|
||||||
|
is_twofactor_provider_usable(&provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"data": twofactors_json,
|
"data": twofactors_json,
|
||||||
@@ -58,11 +106,11 @@ async fn get_twofactor(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-recover", data = "<data>")]
|
#[post("/two-factor/get-recover", data = "<data>")]
|
||||||
async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, true, &mut conn).await?;
|
data.validate(&user, true, &conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": user.totp_recover,
|
"code": user.totp_recover,
|
||||||
@@ -70,57 +118,9 @@ async fn get_recover(data: Json<PasswordOrOtpData>, headers: Headers, mut conn:
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
async fn generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||||
#[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, mut 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, &mut 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, &mut conn).await?;
|
|
||||||
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &mut conn).await?;
|
|
||||||
|
|
||||||
log_user_event(
|
|
||||||
EventType::UserRecovered2fa as i32,
|
|
||||||
&user.uuid,
|
|
||||||
client_headers.device_type,
|
|
||||||
&client_headers.ip.ip,
|
|
||||||
&mut conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Remove the recovery code, not needed without twofactors
|
|
||||||
user.totp_recover = None;
|
|
||||||
user.save(&mut conn).await?;
|
|
||||||
Ok(Json(Value::Object(serde_json::Map::new())))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn _generate_recover_code(user: &mut User, conn: &mut DbConn) {
|
|
||||||
if user.totp_recover.is_none() {
|
if user.totp_recover.is_none() {
|
||||||
let totp_recover = crypto::encode_random_bytes::<20>(BASE32);
|
let totp_recover = crypto::encode_random_bytes::<20>(&BASE32);
|
||||||
user.totp_recover = Some(totp_recover);
|
user.totp_recover = Some(totp_recover);
|
||||||
user.save(conn).await.ok();
|
user.save(conn).await.ok();
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ struct DisableTwoFactorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/disable", data = "<data>")]
|
#[post("/two-factor/disable", data = "<data>")]
|
||||||
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: DisableTwoFactorData = data.into_inner();
|
let data: DisableTwoFactorData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
@@ -144,19 +144,19 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, m
|
|||||||
master_password_hash: data.master_password_hash,
|
master_password_hash: data.master_password_hash,
|
||||||
otp: data.otp,
|
otp: data.otp,
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let type_ = data.r#type.into_i32()?;
|
let type_ = data.r#type.into_i32()?;
|
||||||
|
|
||||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
|
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
twofactor.delete(&mut conn).await?;
|
twofactor.delete(&conn).await?;
|
||||||
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn)
|
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if TwoFactor::find_by_user(&user.uuid, &mut conn).await.is_empty() {
|
if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() {
|
||||||
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await?;
|
enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -176,11 +176,9 @@ pub async fn enforce_2fa_policy(
|
|||||||
act_user_id: &UserId,
|
act_user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
ip: &std::net::IpAddr,
|
ip: &std::net::IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
for member in
|
for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await {
|
||||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
|
||||||
{
|
|
||||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||||
if member.atype < MembershipType::Admin {
|
if member.atype < MembershipType::Admin {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -212,10 +210,10 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
act_user_id: &UserId,
|
act_user_id: &UserId,
|
||||||
device_type: i32,
|
device_type: i32,
|
||||||
ip: &std::net::IpAddr,
|
ip: &std::net::IpAddr,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
||||||
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
|
for member in Membership::find_confirmed_by_org(org_id, conn).await {
|
||||||
// Don't enforce the policy for Admins and Owners.
|
// Don't enforce the policy for Admins and Owners.
|
||||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
@@ -249,20 +247,17 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = match pool.get().await {
|
let Ok(conn) = pool.get().await else {
|
||||||
Ok(conn) => conn,
|
|
||||||
_ => {
|
|
||||||
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
error!("Failed to get DB connection in send_incomplete_2fa_notifications()");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
||||||
let time_before = now - time_limit;
|
let time_before = now - time_limit;
|
||||||
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &mut conn).await;
|
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
||||||
for login in incomplete_logins {
|
for login in incomplete_logins {
|
||||||
let user = User::find_by_uuid(&login.user_uuid, &mut conn).await.expect("User not found");
|
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
||||||
info!(
|
info!(
|
||||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||||
user.email, login.ip_address
|
user.email, login.ip_address
|
||||||
@@ -276,8 +271,8 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
if let Err(e) = login.delete(&mut conn).await {
|
if let Err(e) = login.delete(&conn).await {
|
||||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, TimeDelta, Utc, naive::serde::ts_seconds};
|
||||||
use rocket::{serde::json::Json, Route};
|
use rocket::{Route, serde::json::Json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
models::{TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{TwoFactor, TwoFactorType, UserId},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
mail, CONFIG,
|
mail,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -23,16 +24,17 @@ pub struct ProtectedActionData {
|
|||||||
/// Token issued to validate the protected action
|
/// Token issued to validate the protected action
|
||||||
pub token: String,
|
pub token: String,
|
||||||
/// UNIX timestamp of token issue.
|
/// UNIX timestamp of token issue.
|
||||||
pub token_sent: i64,
|
#[serde(with = "ts_seconds")]
|
||||||
|
pub token_sent: NaiveDateTime,
|
||||||
// The total amount of attempts
|
// The total amount of attempts
|
||||||
pub attempts: u8,
|
pub attempts: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProtectedActionData {
|
impl ProtectedActionData {
|
||||||
pub fn new(token: String) -> Self {
|
pub fn new(token: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
token,
|
token,
|
||||||
token_sent: Utc::now().timestamp(),
|
token_sent: Utc::now().naive_utc(),
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,30 +45,43 @@ impl ProtectedActionData {
|
|||||||
|
|
||||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||||
match res {
|
if let Ok(x) = res {
|
||||||
Ok(x) => Ok(x),
|
Ok(x)
|
||||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
} else {
|
||||||
|
err!("Could not decode ProtectedActionData from string")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_attempt(&mut self) {
|
pub fn add_attempt(&mut self) {
|
||||||
self.attempts += 1;
|
self.attempts = self.attempts.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn time_since_sent(&self) -> TimeDelta {
|
||||||
|
Utc::now().naive_utc() - self.token_sent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/request-otp")]
|
#[post("/accounts/request-otp")]
|
||||||
async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
// Only one Protected Action per user is allowed to take place, delete the previous one
|
// Only one Protected Action per user is allowed to take place, delete the previous one
|
||||||
if let Some(pa) =
|
if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await
|
||||||
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &mut conn).await
|
|
||||||
{
|
{
|
||||||
pa.delete(&mut conn).await?;
|
let pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
|
let elapsed = pa_data.time_since_sent().num_seconds();
|
||||||
|
let delay = 30;
|
||||||
|
if elapsed < delay {
|
||||||
|
err!(format!("Please wait {} seconds before requesting another code.", (delay - elapsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pa.delete(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||||
@@ -74,7 +89,7 @@ async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult {
|
|||||||
|
|
||||||
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
|
||||||
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json());
|
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json());
|
||||||
twofactor.save(&mut conn).await?;
|
twofactor.save(&conn).await?;
|
||||||
|
|
||||||
mail::send_protected_action_token(&user.email, &pa_data.token).await?;
|
mail::send_protected_action_token(&user.email, &pa_data.token).await?;
|
||||||
|
|
||||||
@@ -89,9 +104,11 @@ struct ProtectedActionVerify {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/verify-otp", data = "<data>")]
|
#[post("/accounts/verify-otp", data = "<data>")]
|
||||||
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
|
err!(
|
||||||
|
"Email is disabled for this server. Either enable email or login using your master password instead of login via device."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
@@ -99,33 +116,31 @@ async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, mut con
|
|||||||
|
|
||||||
// Delete the token after one validation attempt
|
// Delete the token after one validation attempt
|
||||||
// This endpoint only gets called for the vault export, and doesn't need a second attempt
|
// This endpoint only gets called for the vault export, and doesn't need a second attempt
|
||||||
validate_protected_action_otp(&data.otp, &user.uuid, true, &mut conn).await
|
validate_protected_action_otp(&data.otp, &user.uuid, true, &conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_protected_action_otp(
|
pub async fn validate_protected_action_otp(
|
||||||
otp: &str,
|
otp: &str,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
delete_if_valid: bool,
|
delete_if_valid: bool,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
|
let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
|
||||||
.await
|
.await
|
||||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
|
|
||||||
pa_data.add_attempt();
|
pa_data.add_attempt();
|
||||||
// Delete the token after x attempts if it has been used too many times
|
pa.data = pa_data.to_json();
|
||||||
// We use the 6, which should be more then enough for invalid attempts and multiple valid checks
|
|
||||||
if pa_data.attempts > 6 {
|
// Fail after x attempts if the token has been used too many times.
|
||||||
pa.delete(conn).await?;
|
// Don't delete it, as we use it to keep track of attempts.
|
||||||
|
if pa_data.attempts >= CONFIG.email_attempts_limit() {
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let date =
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
DateTime::from_timestamp(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid.").naive_utc();
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
let max_time = CONFIG.email_expiration_time() as i64;
|
|
||||||
if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-106
@@ -1,34 +1,36 @@
|
|||||||
use crate::{
|
use std::{str::FromStr, sync::LazyLock, time::Duration};
|
||||||
api::{
|
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
use rocket::{Route, serde::json::Json};
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
|
||||||
},
|
|
||||||
auth::Headers,
|
|
||||||
db::{
|
|
||||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
|
||||||
DbConn,
|
|
||||||
},
|
|
||||||
error::Error,
|
|
||||||
util::NumberOrString,
|
|
||||||
CONFIG,
|
|
||||||
};
|
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::{Arc, LazyLock};
|
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs::prelude::{Base64UrlSafeData, SecurityKey, SecurityKeyAuthentication, SecurityKeyRegistration};
|
use webauthn_rs::{
|
||||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
Webauthn, WebauthnBuilder,
|
||||||
|
prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration},
|
||||||
|
};
|
||||||
use webauthn_rs_proto::{
|
use webauthn_rs_proto::{
|
||||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||||
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
|
||||||
RequestAuthenticationExtensions,
|
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
|
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 = CONFIG.domain();
|
||||||
let domain_origin = CONFIG.domain_origin();
|
let domain_origin = CONFIG.domain_origin();
|
||||||
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
|
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
|
||||||
@@ -37,14 +39,11 @@ pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
|
|||||||
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
|
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
|
||||||
.expect("Creating WebauthnBuilder failed")
|
.expect("Creating WebauthnBuilder failed")
|
||||||
.rp_name(&domain)
|
.rp_name(&domain)
|
||||||
.timeout(Duration::from_millis(60000))
|
.timeout(Duration::from_mins(1));
|
||||||
.danger_set_user_presence_only_security_keys(true);
|
|
||||||
|
|
||||||
Arc::new(webauthn.build().expect("Building Webauthn failed"))
|
webauthn.build().expect("Building Webauthn failed")
|
||||||
});
|
});
|
||||||
|
|
||||||
pub type Webauthn2FaConfig<'a> = &'a rocket::State<Arc<Webauthn>>;
|
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
|
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
|
||||||
}
|
}
|
||||||
@@ -77,7 +76,7 @@ pub struct WebauthnRegistration {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub migrated: bool,
|
pub migrated: bool,
|
||||||
|
|
||||||
pub credential: SecurityKey,
|
pub credential: Passkey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebauthnRegistration {
|
impl WebauthnRegistration {
|
||||||
@@ -88,20 +87,38 @@ impl WebauthnRegistration {
|
|||||||
"migrated": self.migrated,
|
"migrated": self.migrated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_backup_eligible(&mut self, backup_eligible: bool, backup_state: bool) -> bool {
|
||||||
|
let mut changed = false;
|
||||||
|
let mut cred: Credential = self.credential.clone().into();
|
||||||
|
|
||||||
|
if cred.backup_state != backup_state {
|
||||||
|
cred.backup_state = backup_state;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if backup_eligible && !cred.backup_eligible {
|
||||||
|
cred.backup_eligible = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.credential = cred.into();
|
||||||
|
changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||||
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
if !CONFIG.domain_set() {
|
if !CONFIG.is_webauthn_2fa_supported() {
|
||||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
err!("Configured `DOMAIN` is not compatible with Webauthn")
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &mut conn).await?;
|
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?;
|
||||||
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -112,35 +129,39 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, mut conn:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
|
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
|
||||||
async fn generate_webauthn_challenge(
|
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
data: Json<PasswordOrOtpData>,
|
|
||||||
headers: Headers,
|
|
||||||
webauthn: Webauthn2FaConfig<'_>,
|
|
||||||
mut conn: DbConn,
|
|
||||||
) -> JsonResult {
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let registrations = get_webauthn_registrations(&user.uuid, &mut conn)
|
let registrations = get_webauthn_registrations(&user.uuid, &conn)
|
||||||
.await?
|
.await?
|
||||||
.1
|
.1
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
|
.map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (challenge, state) = webauthn.start_securitykey_registration(
|
let (mut challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||||
&user.email,
|
&user.email,
|
||||||
&user.name,
|
user.display_name(),
|
||||||
Some(registrations),
|
Some(registrations),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let mut state = serde_json::to_value(&state)?;
|
||||||
|
state["rs"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
|
state["rs"]["extensions"].as_object_mut().unwrap().clear();
|
||||||
|
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||||
TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?;
|
TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&conn).await?;
|
||||||
|
|
||||||
|
// Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey
|
||||||
|
// we need to modify some of the default settings defined by `start_passkey_registration()`.
|
||||||
|
challenge.public_key.extensions = None;
|
||||||
|
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
|
||||||
|
asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||||
|
}
|
||||||
|
|
||||||
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
||||||
challenge_value["status"] = "ok".into();
|
challenge_value["status"] = "ok".into();
|
||||||
@@ -232,12 +253,7 @@ impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/webauthn", data = "<data>")]
|
#[post("/two-factor/webauthn", data = "<data>")]
|
||||||
async fn activate_webauthn(
|
async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
data: Json<EnableWebauthnData>,
|
|
||||||
headers: Headers,
|
|
||||||
webauthn: Webauthn2FaConfig<'_>,
|
|
||||||
mut conn: DbConn,
|
|
||||||
) -> JsonResult {
|
|
||||||
let data: EnableWebauthnData = data.into_inner();
|
let data: EnableWebauthnData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -245,24 +261,23 @@ async fn activate_webauthn(
|
|||||||
master_password_hash: data.master_password_hash,
|
master_password_hash: data.master_password_hash,
|
||||||
otp: data.otp,
|
otp: data.otp,
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Retrieve and delete the saved challenge state
|
// Retrieve and delete the saved challenge state
|
||||||
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
|
||||||
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
|
let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||||
let state: SecurityKeyRegistration = serde_json::from_str(&tf.data)?;
|
tf.delete(&conn).await?;
|
||||||
tf.delete(&mut conn).await?;
|
|
||||||
state
|
state
|
||||||
}
|
} else {
|
||||||
None => err!("Can't recover challenge"),
|
err!("Can't recover challenge")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify the credentials with the saved state
|
// Verify the credentials with the saved state
|
||||||
let credential = webauthn.finish_securitykey_registration(&data.device_response.into(), &state)?;
|
let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?;
|
||||||
|
|
||||||
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1;
|
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1;
|
||||||
// TODO: Check for repeated ID's
|
// TODO: Check for repeated ID's
|
||||||
registrations.push(WebauthnRegistration {
|
registrations.push(WebauthnRegistration {
|
||||||
id: data.id.into_i32()?,
|
id: data.id.into_i32()?,
|
||||||
@@ -274,11 +289,11 @@ async fn activate_webauthn(
|
|||||||
|
|
||||||
// Save the registrations and return them
|
// Save the registrations and return them
|
||||||
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||||
.save(&mut conn)
|
.save(&conn)
|
||||||
.await?;
|
.await?;
|
||||||
_generate_recover_code(&mut user, &mut conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -289,13 +304,8 @@ async fn activate_webauthn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/two-factor/webauthn", data = "<data>")]
|
#[put("/two-factor/webauthn", data = "<data>")]
|
||||||
async fn activate_webauthn_put(
|
async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
data: Json<EnableWebauthnData>,
|
activate_webauthn(data, headers, conn).await
|
||||||
headers: Headers,
|
|
||||||
webauthn: Webauthn2FaConfig<'_>,
|
|
||||||
conn: DbConn,
|
|
||||||
) -> JsonResult {
|
|
||||||
activate_webauthn(data, headers, webauthn, conn).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -306,14 +316,14 @@ struct DeleteU2FData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/two-factor/webauthn", data = "<data>")]
|
#[delete("/two-factor/webauthn", data = "<data>")]
|
||||||
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let id = data.id.into_i32()?;
|
let id = data.id.into_i32()?;
|
||||||
if !headers.user.check_valid_password(&data.master_password_hash) {
|
if !headers.user.check_valid_password(&data.master_password_hash) {
|
||||||
err!("Invalid password");
|
err!("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(mut tf) =
|
let Some(mut tf) =
|
||||||
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &mut conn).await
|
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Webauthn data not found!")
|
err!("Webauthn data not found!")
|
||||||
};
|
};
|
||||||
@@ -326,23 +336,23 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn:
|
|||||||
|
|
||||||
let removed_item = data.remove(item_pos);
|
let removed_item = data.remove(item_pos);
|
||||||
tf.data = serde_json::to_string(&data)?;
|
tf.data = serde_json::to_string(&data)?;
|
||||||
tf.save(&mut conn).await?;
|
tf.save(&conn).await?;
|
||||||
drop(tf);
|
drop(tf);
|
||||||
|
|
||||||
// If entry is migrated from u2f, delete the u2f entry as well
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||||
if let Some(mut u2f) =
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
|
||||||
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &mut conn).await
|
|
||||||
{
|
{
|
||||||
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
|
let mut data: Vec<U2FRegistration> = if let Ok(d) = serde_json::from_str(&u2f.data) {
|
||||||
Ok(d) => d,
|
d
|
||||||
Err(_) => err!("Error parsing U2F data"),
|
} else {
|
||||||
|
err!("Error parsing U2F data")
|
||||||
};
|
};
|
||||||
|
|
||||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||||
let new_data_str = serde_json::to_string(&data)?;
|
let new_data_str = serde_json::to_string(&data)?;
|
||||||
|
|
||||||
u2f.data = new_data_str;
|
u2f.data = new_data_str;
|
||||||
u2f.save(&mut conn).await?;
|
u2f.save(&conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
|
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
|
||||||
@@ -356,7 +366,7 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn:
|
|||||||
|
|
||||||
pub async fn get_webauthn_registrations(
|
pub async fn get_webauthn_registrations(
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
||||||
let type_ = TwoFactorType::Webauthn as i32;
|
let type_ = TwoFactorType::Webauthn as i32;
|
||||||
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||||
@@ -365,27 +375,27 @@ pub async fn get_webauthn_registrations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_webauthn_login(
|
pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult {
|
||||||
user_id: &UserId,
|
|
||||||
webauthn: Webauthn2FaConfig<'_>,
|
|
||||||
conn: &mut DbConn,
|
|
||||||
) -> JsonResult {
|
|
||||||
// Load saved credentials
|
// Load saved credentials
|
||||||
let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
|
let creds: Vec<Passkey> =
|
||||||
|
get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
|
||||||
|
|
||||||
if creds.is_empty() {
|
if creds.is_empty() {
|
||||||
err!("No Webauthn devices registered")
|
err!("No Webauthn devices registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a challenge based on the credentials
|
// Generate a challenge based on the credentials
|
||||||
let (mut response, state) = webauthn.start_securitykey_authentication(&creds)?;
|
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&creds)?;
|
||||||
|
|
||||||
// Modify to discourage user verification
|
// Modify to discourage user verification
|
||||||
let mut state = serde_json::to_value(&state)?;
|
let mut state = serde_json::to_value(&state)?;
|
||||||
|
state["ast"]["policy"] = Value::String("discouraged".to_owned());
|
||||||
|
|
||||||
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
// Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well
|
||||||
let app_id = format!("{}/app-id.json", &CONFIG.domain());
|
let app_id = format!("{}/app-id.json", CONFIG.domain());
|
||||||
state["ast"]["appid"] = Value::String(app_id.clone());
|
state["ast"]["appid"] = Value::String(app_id.clone());
|
||||||
|
|
||||||
|
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
|
||||||
response
|
response
|
||||||
.public_key
|
.public_key
|
||||||
.extensions
|
.extensions
|
||||||
@@ -405,25 +415,19 @@ pub async fn generate_webauthn_login(
|
|||||||
Ok(Json(serde_json::to_value(response.public_key)?))
|
Ok(Json(serde_json::to_value(response.public_key)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_webauthn_login(
|
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult {
|
||||||
user_id: &UserId,
|
|
||||||
response: &str,
|
|
||||||
webauthn: Webauthn2FaConfig<'_>,
|
|
||||||
conn: &mut DbConn,
|
|
||||||
) -> EmptyResult {
|
|
||||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||||
let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||||
Some(tf) => {
|
let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
|
||||||
let state: SecurityKeyAuthentication = serde_json::from_str(&tf.data)?;
|
|
||||||
tf.delete(conn).await?;
|
tf.delete(conn).await?;
|
||||||
state
|
state
|
||||||
}
|
} else {
|
||||||
None => err!(
|
err!(
|
||||||
"Can't recover login challenge",
|
"Can't recover login challenge",
|
||||||
ErrorEvent {
|
ErrorEvent {
|
||||||
event: EventType::UserFailedLogIn2fa
|
event: EventType::UserFailedLogIn2fa
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||||
@@ -431,15 +435,23 @@ pub async fn validate_webauthn_login(
|
|||||||
|
|
||||||
let mut registrations = get_webauthn_registrations(user_id, conn).await?.1;
|
let mut registrations = get_webauthn_registrations(user_id, conn).await?.1;
|
||||||
|
|
||||||
let authentication_result = webauthn.finish_securitykey_authentication(&rsp, &state)?;
|
// 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
|
||||||
|
let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
|
||||||
|
|
||||||
|
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
|
||||||
|
|
||||||
for reg in &mut registrations {
|
for reg in &mut registrations {
|
||||||
if reg.credential.cred_id() == authentication_result.cred_id() && authentication_result.needs_update() {
|
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
|
||||||
reg.credential.update_credential(&authentication_result);
|
// If the cred id matches and the credential is updated, Some(true) is returned
|
||||||
|
// In those cases, update the record, else leave it alone
|
||||||
|
let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true);
|
||||||
|
if credential_updated || backup_flags_updated {
|
||||||
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||||
.save(conn)
|
.save(conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,3 +463,56 @@ pub async fn validate_webauthn_login(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_and_update_backup_eligible(
|
||||||
|
rsp: &PublicKeyCredential,
|
||||||
|
registrations: &mut Vec<WebauthnRegistration>,
|
||||||
|
state: &mut PasskeyAuthentication,
|
||||||
|
) -> 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;
|
||||||
|
const FLAG_BACKUP_STATE: u8 = 0b0001_0000;
|
||||||
|
|
||||||
|
if let Some(bits) = rsp.response.authenticator_data.get(32) {
|
||||||
|
let backup_eligible = 0 != (bits & FLAG_BACKUP_ELIGIBLE);
|
||||||
|
let backup_state = 0 != (bits & FLAG_BACKUP_STATE);
|
||||||
|
|
||||||
|
// If the current key is backup eligible, then we probably need to update one of the keys already stored in the database
|
||||||
|
// This is needed because Vaultwarden didn't store this information when using the previous version of webauthn-rs since it was a new addition to the protocol
|
||||||
|
// Because we store multiple keys in one json string, we need to fetch the correct key first, and update its information before we let it verify
|
||||||
|
if 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) {
|
||||||
|
if reg.set_backup_eligible(backup_eligible, backup_state) {
|
||||||
|
// 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)?;
|
||||||
|
if let Some(credentials) = raw_state
|
||||||
|
.get_mut("ast")
|
||||||
|
.and_then(|v| v.get_mut("credentials"))
|
||||||
|
.and_then(|v| v.as_array_mut())
|
||||||
|
{
|
||||||
|
for cred in credentials.iter_mut() {
|
||||||
|
if cred.get("cred_id").is_some_and(|v| {
|
||||||
|
// Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id`
|
||||||
|
let cred_id_slice: Base64UrlSafeData = serde_json::from_value(v.clone()).unwrap();
|
||||||
|
ct_eq(cred_id_slice, rsp_id)
|
||||||
|
}) {
|
||||||
|
cred["backup_eligible"] = Value::Bool(backup_eligible);
|
||||||
|
cred["backup_state"] = Value::Bool(backup_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*state = serde_json::from_value(raw_state)?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
use rocket::serde::json::Json;
|
use rocket::{Route, serde::json::Json};
|
||||||
use rocket::Route;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yubico::{config::Config, verify_async};
|
use yubico::{config::Config, verify_async};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::_generate_recover_code},
|
|
||||||
EmptyResult, JsonResult, PasswordOrOtpData,
|
EmptyResult, JsonResult, PasswordOrOtpData,
|
||||||
|
core::{log_user_event, two_factor::generate_recover_code},
|
||||||
},
|
},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::{
|
db::{
|
||||||
models::{EventType, TwoFactor, TwoFactorType},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{EventType, TwoFactor, TwoFactorType},
|
||||||
},
|
},
|
||||||
error::{Error, MapResult},
|
error::{Error, MapResult},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -46,7 +45,7 @@ pub struct YubikeyMetadata {
|
|||||||
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||||
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
|
||||||
|
|
||||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
data_keys.into_iter().flatten().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||||
@@ -64,9 +63,10 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
|
|||||||
err!("Yubico support is disabled");
|
err!("Yubico support is disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
if let (Some(id), Some(secret)) = (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
|
||||||
(Some(id), Some(secret)) => Ok((id, secret)),
|
Ok((id, secret))
|
||||||
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
|
} else {
|
||||||
|
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +83,19 @@ async fn verify_yubikey_otp(otp: String) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/get-yubikey", data = "<data>")]
|
#[post("/two-factor/get-yubikey", data = "<data>")]
|
||||||
async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
// Make sure the credentials are set
|
// Make sure the credentials are set
|
||||||
get_yubico_credentials()?;
|
get_yubico_credentials()?;
|
||||||
|
|
||||||
let data: PasswordOrOtpData = data.into_inner();
|
let data: PasswordOrOtpData = data.into_inner();
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
|
|
||||||
data.validate(&user, false, &mut conn).await?;
|
data.validate(&user, false, &conn).await?;
|
||||||
|
|
||||||
let user_id = &user.uuid;
|
let user_id = &user.uuid;
|
||||||
let yubikey_type = TwoFactorType::YubiKey as i32;
|
let yubikey_type = TwoFactorType::YubiKey as i32;
|
||||||
|
|
||||||
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &mut conn).await;
|
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await;
|
||||||
|
|
||||||
if let Some(r) = r {
|
if let Some(r) = r {
|
||||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||||
@@ -116,7 +116,7 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, mut c
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/yubikey", data = "<data>")]
|
#[post("/two-factor/yubikey", data = "<data>")]
|
||||||
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data: EnableYubikeyData = data.into_inner();
|
let data: EnableYubikeyData = data.into_inner();
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
@@ -124,12 +124,12 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, mut c
|
|||||||
master_password_hash: data.master_password_hash.clone(),
|
master_password_hash: data.master_password_hash.clone(),
|
||||||
otp: data.otp.clone(),
|
otp: data.otp.clone(),
|
||||||
}
|
}
|
||||||
.validate(&user, true, &mut conn)
|
.validate(&user, true, &conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check if we already have some data
|
// Check if we already have some data
|
||||||
let mut yubikey_data =
|
let mut yubikey_data =
|
||||||
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &mut conn).await {
|
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await {
|
||||||
Some(data) => data,
|
Some(data) => data,
|
||||||
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
|
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
|
||||||
};
|
};
|
||||||
@@ -160,11 +160,11 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, mut c
|
|||||||
};
|
};
|
||||||
|
|
||||||
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
|
||||||
yubikey_data.save(&mut conn).await?;
|
yubikey_data.save(&conn).await?;
|
||||||
|
|
||||||
_generate_recover_code(&mut user, &mut conn).await;
|
generate_recover_code(&mut user, &conn).await;
|
||||||
|
|
||||||
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await;
|
log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
|
||||||
|
|
||||||
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
let mut result = jsonify_yubikeys(yubikey_metadata.keys);
|
||||||
|
|
||||||
|
|||||||
+117
-122
@@ -1,39 +1,38 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
net::IpAddr,
|
net::IpAddr,
|
||||||
sync::Arc,
|
sync::{Arc, LazyLock},
|
||||||
time::{Duration, SystemTime},
|
time::{Duration, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bytes::{Bytes, BytesMut};
|
use bytes::{Bytes, BytesMut};
|
||||||
use futures::{stream::StreamExt, TryFutureExt};
|
use futures::{TryFutureExt, stream::StreamExt};
|
||||||
use once_cell::sync::Lazy;
|
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{self, HeaderMap, HeaderValue},
|
|
||||||
Client, Response,
|
Client, Response,
|
||||||
|
header::{self, HeaderMap, HeaderValue},
|
||||||
};
|
};
|
||||||
use rocket::{http::ContentType, response::Redirect, Route};
|
use rocket::{Route, http::ContentType, response::Redirect};
|
||||||
use svg_hush::{data_url_filter, Filter};
|
use svg_hush::{Filter, data_url_filter};
|
||||||
|
|
||||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
config::PathType,
|
config::PathType,
|
||||||
error::Error,
|
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,
|
util::Cached,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
match CONFIG.icon_service().as_str() {
|
if CONFIG.icon_service().as_str() == "internal" {
|
||||||
"internal" => routes![icon_internal],
|
routes![icon_internal]
|
||||||
_ => routes![icon_external],
|
} else {
|
||||||
|
routes![icon_external]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static CLIENT: Lazy<Client> = Lazy::new(|| {
|
static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
||||||
// Generate the default headers
|
// Generate the default headers
|
||||||
let mut default_headers = HeaderMap::new();
|
let mut default_headers = HeaderMap::new();
|
||||||
default_headers.insert(
|
default_headers.insert(
|
||||||
@@ -66,7 +65,7 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
|||||||
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder()
|
get_reqwest_client_builder(true)
|
||||||
.cookie_provider(Arc::clone(&cookie_store))
|
.cookie_provider(Arc::clone(&cookie_store))
|
||||||
.timeout(icon_download_timeout)
|
.timeout(icon_download_timeout)
|
||||||
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
||||||
@@ -78,25 +77,25 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build Regex only once since this takes a lot of time.
|
// Build Regex only once since this takes a lot of time.
|
||||||
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
||||||
|
|
||||||
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
||||||
// It is used to prevent sending a specific header which breaks icon downloads.
|
// It is used to prevent sending a specific header which breaks icon downloads.
|
||||||
// If this function needs to be renamed, also adjust the code in `util.rs`
|
// If this function needs to be renamed, also adjust the code in `util.rs`
|
||||||
#[get("/<domain>/icon.png")]
|
#[get("/<host>/icon.png")]
|
||||||
fn icon_external(domain: &str) -> Option<Redirect> {
|
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
|
||||||
if !is_valid_domain(domain) {
|
let Ok(host) = get_valid_host(host) else {
|
||||||
warn!("Invalid domain: {domain}");
|
warn!("Invalid host: {host}");
|
||||||
return None;
|
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) {
|
let url = CONFIG._icon_service_url().replace("{}", &host.to_string());
|
||||||
warn!("Blocked address: {domain}");
|
let redir = match CONFIG.icon_redirect_code() {
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = CONFIG._icon_service_url().replace("{}", domain);
|
|
||||||
match CONFIG.icon_redirect_code() {
|
|
||||||
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
301 => Some(Redirect::moved(url)), // legacy permanent redirect
|
||||||
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
302 => Some(Redirect::found(url)), // legacy temporary redirect
|
||||||
307 => Some(Redirect::temporary(url)),
|
307 => Some(Redirect::temporary(url)),
|
||||||
@@ -105,15 +104,25 @@ fn icon_external(domain: &str) -> Option<Redirect> {
|
|||||||
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
|
error!("Unexpected redirect code {}", CONFIG.icon_redirect_code());
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/<domain>/icon.png")]
|
#[get("/<host>/icon.png")]
|
||||||
async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||||
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
|
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
|
||||||
|
|
||||||
if !is_valid_domain(domain) {
|
let Ok(host) = get_valid_host(host) else {
|
||||||
warn!("Invalid domain: {domain}");
|
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(
|
return Cached::ttl(
|
||||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||||
CONFIG.icon_cache_negttl(),
|
CONFIG.icon_cache_negttl(),
|
||||||
@@ -121,16 +130,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_block_address(domain) {
|
match get_icon(&host.to_string()).await {
|
||||||
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 {
|
|
||||||
Some((icon, icon_type)) => {
|
Some((icon, icon_type)) => {
|
||||||
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
|
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
|
||||||
}
|
}
|
||||||
@@ -138,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)> {
|
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||||
let path = format!("{domain}.png");
|
let path = format!("{domain}.png");
|
||||||
|
|
||||||
@@ -184,7 +148,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
|
|
||||||
if let Some(icon) = get_cached_icon(&path).await {
|
if let Some(icon) = get_cached_icon(&path).await {
|
||||||
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
let icon_type = get_icon_type(&icon).unwrap_or("x-icon");
|
||||||
return Some((icon, icon_type.to_string()));
|
return Some((icon, icon_type.to_owned()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.disable_icon_download() {
|
if CONFIG.disable_icon_download() {
|
||||||
@@ -195,7 +159,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
|||||||
match download_icon(domain).await {
|
match download_icon(domain).await {
|
||||||
Ok((icon, icon_type)) => {
|
Ok((icon, icon_type)) => {
|
||||||
save_icon(&path, icon.to_vec()).await;
|
save_icon(&path, icon.to_vec()).await;
|
||||||
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
|
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_owned()))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// If this error comes from the custom resolver, this means this is a blocked domain
|
// If this error comes from the custom resolver, this means this is a blocked domain
|
||||||
@@ -220,17 +184,17 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to read the cached icon, and return it if it exists
|
// Try to read the cached icon, and return it if it exists
|
||||||
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)
|
||||||
if let Ok(buf) = operator.read(path).await {
|
&& let Ok(buf) = operator.read(path).await
|
||||||
|
{
|
||||||
return Some(buf.to_vec());
|
return Some(buf.to_vec());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::IconCache)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||||
let meta = operator.stat(path).await?;
|
let meta = operator.stat(path).await?;
|
||||||
let modified =
|
let modified =
|
||||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||||
@@ -246,7 +210,7 @@ async fn icon_is_negcached(path: &str) -> bool {
|
|||||||
match expired {
|
match expired {
|
||||||
// No longer negatively cached, drop the marker
|
// No longer negatively cached, drop the marker
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||||
Ok(operator) => {
|
Ok(operator) => {
|
||||||
if let Err(e) = operator.delete(&miss_indicator).await {
|
if let Err(e) = operator.delete(&miss_indicator).await {
|
||||||
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}");
|
error!("Could not remove negative cache indicator for icon {path:?}: {e:?}");
|
||||||
@@ -317,8 +281,9 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
}
|
}
|
||||||
|
|
||||||
for icon_tag in icon_tags {
|
for icon_tag in icon_tags {
|
||||||
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) {
|
if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF)
|
||||||
if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) {
|
&& let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default())
|
||||||
|
{
|
||||||
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) {
|
||||||
std::str::from_utf8(v).unwrap_or_default()
|
std::str::from_utf8(v).unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
@@ -327,7 +292,6 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
let priority = get_icon_priority(full_href.as_str(), sizes);
|
let priority = get_icon_priority(full_href.as_str(), sizes);
|
||||||
icons.push(Icon::new(priority, full_href.to_string()));
|
icons.push(Icon::new(priority, full_href.to_string()));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +332,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
|||||||
tld = domain_parts.next_back().unwrap(),
|
tld = domain_parts.next_back().unwrap(),
|
||||||
base = 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 sslbase = format!("https://{base_domain}");
|
||||||
let httpbase = format!("http://{base_domain}");
|
let httpbase = format!("http://{base_domain}");
|
||||||
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
|
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
|
||||||
@@ -379,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.
|
// 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 {
|
} else if is_ip.is_err() && domain.matches('.').count() < 2 {
|
||||||
let www_domain = format!("www.{domain}");
|
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 sslwww = format!("https://{www_domain}");
|
||||||
let httpwww = format!("http://{www_domain}");
|
let httpwww = format!("http://{www_domain}");
|
||||||
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
|
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
|
||||||
@@ -441,9 +405,25 @@ async fn get_page(url: &str) -> Result<Response, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
|
||||||
|
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
|
||||||
|
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
|
||||||
|
let Ok(parsed_url) = url::Url::parse(url) else {
|
||||||
|
err_silent!("Invalid URL", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(parsed_url.scheme(), "http" | "https") {
|
||||||
|
err_silent!("Invalid scheme", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(host) = parsed_url.host() else {
|
||||||
|
err_silent!("Invalid host", url)
|
||||||
|
};
|
||||||
|
|
||||||
|
should_block_host(&host)?;
|
||||||
|
|
||||||
let mut client = CLIENT.get(url);
|
let mut client = CLIENT.get(url);
|
||||||
if !referer.is_empty() {
|
if !referer.is_empty() {
|
||||||
client = client.header("Referer", referer)
|
client = client.header("Referer", referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(client.send().await?.error_for_status()?)
|
Ok(client.send().await?.error_for_status()?)
|
||||||
@@ -462,8 +442,8 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Err
|
|||||||
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
|
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
|
||||||
/// ```
|
/// ```
|
||||||
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
||||||
static PRIORITY_MAP: Lazy<HashMap<&'static str, u8>> =
|
static PRIORITY_MAP: LazyLock<HashMap<&'static str, u8>> =
|
||||||
Lazy::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
|
LazyLock::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
|
||||||
|
|
||||||
// Check if there is a dimension set
|
// Check if there is a dimension set
|
||||||
let (width, height) = parse_sizes(sizes);
|
let (width, height) = parse_sizes(sizes);
|
||||||
@@ -514,13 +494,11 @@ fn parse_sizes(sizes: &str) -> (u16, u16) {
|
|||||||
|
|
||||||
if !sizes.is_empty() {
|
if !sizes.is_empty() {
|
||||||
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
match ICON_SIZE_REGEX.captures(sizes.trim()) {
|
||||||
None => {}
|
Some(dimensions) if dimensions.len() >= 3 => {
|
||||||
Some(dimensions) => {
|
|
||||||
if dimensions.len() >= 3 {
|
|
||||||
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
width = dimensions[1].parse::<u16>().unwrap_or_default();
|
||||||
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
height = dimensions[2].parse::<u16>().unwrap_or_default();
|
||||||
}
|
}
|
||||||
}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,11 +511,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
let mut buffer = Bytes::new();
|
let mut buffer = Bytes::new();
|
||||||
let mut icon_type: Option<&str> = None;
|
let mut icon_type: Option<&str> = None;
|
||||||
|
|
||||||
use data_url::DataUrl;
|
let mut icons = icon_result.iconlist.iter().take(5).peekable();
|
||||||
|
while let Some(icon) = icons.next() {
|
||||||
for icon in icon_result.iconlist.iter().take(5) {
|
|
||||||
if icon.href.starts_with("data:image") {
|
if icon.href.starts_with("data:image") {
|
||||||
let Ok(datauri) = DataUrl::process(&icon.href) else {
|
let Ok(datauri) = data_url::DataUrl::process(&icon.href) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Check if we are able to decode the data uri
|
// Check if we are able to decode the data uri
|
||||||
@@ -561,13 +538,25 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => debug!("Extracted icon from data:image uri is invalid"),
|
_ => debug!("Extracted icon from data:image uri is invalid"),
|
||||||
};
|
}
|
||||||
} else {
|
} else {
|
||||||
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)
|
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);
|
icon_type = get_icon_type(&buffer);
|
||||||
if icon_type.is_none() {
|
if icon_type.is_none() {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@@ -597,7 +586,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_icon(path: &str, icon: Vec<u8>) {
|
async fn save_icon(path: &str, icon: Vec<u8>) {
|
||||||
let operator = match CONFIG.opendal_operator_for_path_type(PathType::IconCache) {
|
let operator = match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) {
|
||||||
Ok(operator) => operator,
|
Ok(operator) => operator,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to get OpenDAL operator while saving icon: {e}");
|
warn!("Failed to get OpenDAL operator while saving icon: {e}");
|
||||||
@@ -613,21 +602,24 @@ async fn save_icon(path: &str, icon: Vec<u8>) {
|
|||||||
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
|
||||||
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> {
|
||||||
// Look for SVG tag within the first 1KB
|
// Look for SVG tag within the first 1KB
|
||||||
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) {
|
if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)])
|
||||||
if content.contains("<svg") || content.contains("<SVG") {
|
&& (content.contains("<svg") || content.contains("<SVG"))
|
||||||
|
{
|
||||||
return Some("svg+xml");
|
return Some("svg+xml");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
match bytes {
|
||||||
[137, 80, 78, 71, ..] => Some("png"),
|
[137, 80, 78, 71, 13, 10, 26, 10, ..] => Some("png"),
|
||||||
[0, 0, 1, 0, ..] => Some("x-icon"),
|
[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, ..] => Some("webp"),
|
[82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images
|
||||||
[255, 216, 255, ..] => Some("jpeg"),
|
[255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"),
|
||||||
[71, 73, 70, 56, ..] => Some("gif"),
|
[71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"),
|
||||||
[66, 77, ..] => Some("bmp"),
|
[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, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
|
||||||
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
|
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -641,9 +633,9 @@ async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result<Bytes,
|
|||||||
let mut buf = BytesMut::new();
|
let mut buf = BytesMut::new();
|
||||||
let mut size = 0;
|
let mut size = 0;
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
// It is possible that there might occure UnexpectedEof errors or others
|
// It is possible that there might occur UnexpectedEof errors or others
|
||||||
// This is most of the time no issue, and if there is no chunked data anymore or at all parsing the HTML will not happen anyway.
|
// This is most of the time no issue, and if there is no chunked data anymore or at all parsing the HTML will not happen anyway.
|
||||||
// Therfore if chunk is an err, just break and continue with the data be have received.
|
// Therefore if chunk is an err, just break and continue with the data be have received.
|
||||||
if chunk.is_err() {
|
if chunk.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -756,7 +748,7 @@ impl FaviconEmitter {
|
|||||||
let rel_value =
|
let rel_value =
|
||||||
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default();
|
||||||
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
if rel_value.contains("icon") && !rel_value.contains("mask-icon") {
|
||||||
self.emit_token = true
|
self.emit_token = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
@@ -798,8 +790,11 @@ impl Emitter for FaviconEmitter {
|
|||||||
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
fn emit_current_tag(&mut self) -> Option<html5gum::State> {
|
||||||
self.flush_current_attribute(true);
|
self.flush_current_attribute(true);
|
||||||
self.last_start_tag.clear();
|
self.last_start_tag.clear();
|
||||||
if self.current_token.is_some() && !self.current_token.as_ref().unwrap().closing {
|
match &self.current_token {
|
||||||
self.last_start_tag.extend(&*self.current_token.as_ref().unwrap().tag.name);
|
Some(token) if !token.closing => {
|
||||||
|
self.last_start_tag.extend(&*token.tag.name);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
html5gum::naive_next_state(&self.last_start_tag)
|
html5gum::naive_next_state(&self.last_start_tag)
|
||||||
}
|
}
|
||||||
@@ -826,13 +821,13 @@ impl Emitter for FaviconEmitter {
|
|||||||
|
|
||||||
fn push_attribute_name(&mut self, s: &[u8]) {
|
fn push_attribute_name(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.0.extend(s)
|
attr.0.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_attribute_value(&mut self, s: &[u8]) {
|
fn push_attribute_value(&mut self, s: &[u8]) {
|
||||||
if let Some(attr) = &mut self.current_attribute {
|
if let Some(attr) = &mut self.current_attribute {
|
||||||
attr.1.extend(s)
|
attr.1.extend(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+426
-226
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -32,11 +32,13 @@ pub use crate::api::{
|
|||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::static_files,
|
web::static_files,
|
||||||
};
|
};
|
||||||
use crate::db::{
|
use crate::{
|
||||||
models::{OrgPolicy, OrgPolicyType, User},
|
CONFIG,
|
||||||
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{OrgPolicy, OrgPolicyType, User},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use crate::CONFIG;
|
|
||||||
|
|
||||||
// Type aliases for API methods results
|
// Type aliases for API methods results
|
||||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||||
@@ -47,6 +49,7 @@ pub type EmptyResult = ApiResult<()>;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct PasswordOrOtpData {
|
struct PasswordOrOtpData {
|
||||||
|
#[serde(alias = "MasterPasswordHash")]
|
||||||
master_password_hash: Option<String>,
|
master_password_hash: Option<String>,
|
||||||
otp: Option<String>,
|
otp: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -55,7 +58,7 @@ impl PasswordOrOtpData {
|
|||||||
/// Tokens used via this struct can be used multiple times during the process
|
/// Tokens used via this struct can be used multiple times during the process
|
||||||
/// First for the validation to continue, after that to enable or validate the following actions
|
/// First for the validation to continue, after that to enable or validate the following actions
|
||||||
/// This is different per caller, so it can be adjusted to delete the token or not
|
/// This is different per caller, so it can be adjusted to delete the token or not
|
||||||
pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &mut DbConn) -> EmptyResult {
|
pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult {
|
||||||
use crate::api::core::two_factor::protected_actions::validate_protected_action_otp;
|
use crate::api::core::two_factor::protected_actions::validate_protected_action_otp;
|
||||||
|
|
||||||
match (self.master_password_hash.as_deref(), self.otp.as_deref()) {
|
match (self.master_password_hash.as_deref(), self.otp.as_deref()) {
|
||||||
@@ -73,6 +76,7 @@ impl PasswordOrOtpData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MasterPasswordPolicy {
|
pub struct MasterPasswordPolicy {
|
||||||
@@ -110,8 +114,8 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
|
|||||||
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
|
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else if let Some(policy_str) = CONFIG.sso_master_password_policy().filter(|_| CONFIG.sso_enabled()) {
|
} else if CONFIG.sso_enabled() {
|
||||||
serde_json::from_str(&policy_str).unwrap_or(json!({}))
|
CONFIG.sso_master_password_policy_value().unwrap_or(json!({}))
|
||||||
} else {
|
} else {
|
||||||
json!({})
|
json!({})
|
||||||
};
|
};
|
||||||
|
|||||||
+109
-61
@@ -1,41 +1,47 @@
|
|||||||
use std::{net::IpAddr, sync::Arc, time::Duration};
|
use std::{
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{Arc, LazyLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use rmpv::Value;
|
use rmpv::Value;
|
||||||
use rocket::{futures::StreamExt, Route};
|
use rocket::{Route, futures::StreamExt};
|
||||||
|
use rocket_ws::{Message, WebSocket};
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use rocket_ws::{Message, WebSocket};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG, Error,
|
||||||
auth::{ClientIp, WsAccessTokenHeader},
|
auth::{ClientIp, WsAccessTokenHeader},
|
||||||
db::{
|
db::{
|
||||||
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
|
||||||
DbConn,
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId},
|
||||||
},
|
},
|
||||||
Error, CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
|
|
||||||
pub static WS_USERS: Lazy<Arc<WebSocketUsers>> = Lazy::new(|| {
|
|
||||||
Arc::new(WebSocketUsers {
|
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
pub static WS_ANONYMOUS_SUBSCRIPTIONS: Lazy<Arc<AnonymousWebSocketSubscriptions>> = Lazy::new(|| {
|
|
||||||
Arc::new(AnonymousWebSocketSubscriptions {
|
|
||||||
map: Arc::new(dashmap::DashMap::new()),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout,
|
||||||
push_send_update, push_user_update,
|
push_send_update, push_user_update,
|
||||||
};
|
};
|
||||||
|
|
||||||
static NOTIFICATIONS_DISABLED: Lazy<bool> = Lazy::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
|
||||||
|
Arc::new(WebSocketUsers {
|
||||||
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
|
||||||
|
Arc::new(AnonymousWebSocketSubscriptions {
|
||||||
|
map: Arc::new(dashmap::DashMap::new()),
|
||||||
|
connections: Arc::new(dashmap::DashMap::new()),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
|
||||||
|
/// One connection is needed per pending login request, several at once are only expected behind NAT.
|
||||||
|
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
|
||||||
|
|
||||||
|
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
@@ -81,14 +87,21 @@ impl Drop for WSEntryMapGuard {
|
|||||||
struct WSAnonymousEntryMapGuard {
|
struct WSAnonymousEntryMapGuard {
|
||||||
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
token: String,
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
addr: IpAddr,
|
addr: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WSAnonymousEntryMapGuard {
|
impl WSAnonymousEntryMapGuard {
|
||||||
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self {
|
fn new(
|
||||||
|
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
|
||||||
|
token: String,
|
||||||
|
entry_uuid: uuid::Uuid,
|
||||||
|
addr: IpAddr,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
token,
|
token,
|
||||||
|
entry_uuid,
|
||||||
addr,
|
addr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,11 +110,15 @@ impl WSAnonymousEntryMapGuard {
|
|||||||
impl Drop for WSAnonymousEntryMapGuard {
|
impl Drop for WSAnonymousEntryMapGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
info!("Closing WS connection from {}", self.addr);
|
info!("Closing WS connection from {}", self.addr);
|
||||||
self.subscriptions.map.remove(&self.token);
|
if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
|
||||||
|
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
|
||||||
|
}
|
||||||
|
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
|
||||||
|
self.subscriptions.release(self.addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/hub?<data..>")]
|
#[get("/hub?<data..>")]
|
||||||
fn websockets_hub<'r>(
|
fn websockets_hub<'r>(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -109,8 +126,7 @@ fn websockets_hub<'r>(
|
|||||||
ip: ClientIp,
|
ip: ClientIp,
|
||||||
header_token: WsAccessTokenHeader,
|
header_token: WsAccessTokenHeader,
|
||||||
) -> Result<rocket_ws::Stream!['r], Error> {
|
) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
let addr = ip.ip;
|
info!("Accepting Rocket WS connection from {}", ip.ip);
|
||||||
info!("Accepting Rocket WS connection from {addr}");
|
|
||||||
|
|
||||||
let token = if let Some(token) = data.access_token {
|
let token = if let Some(token) = data.access_token {
|
||||||
token
|
token
|
||||||
@@ -133,7 +149,7 @@ fn websockets_hub<'r>(
|
|||||||
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
|
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, addr))
|
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -186,21 +202,27 @@ fn websockets_hub<'r>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(tail_expr_drop_order)]
|
#[expect(tail_expr_drop_order)]
|
||||||
#[get("/anonymous-hub?<token..>")]
|
#[get("/anonymous-hub?<token..>")]
|
||||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||||
let addr = ip.ip;
|
info!("Accepting Anonymous Rocket WS connection from {}", ip.ip);
|
||||||
info!("Accepting Anonymous Rocket WS connection from {addr}");
|
|
||||||
|
|
||||||
let (mut rx, guard) = {
|
let (mut rx, guard) = {
|
||||||
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
|
||||||
|
|
||||||
// Add a channel to send messages to this client to the map
|
if !subscriptions.try_reserve(ip.ip) {
|
||||||
|
err_code!("Too many connections", 429)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a channel to send messages to this client to the map.
|
||||||
|
// Clients reconnect with the same token while a login request is still pending, so keep
|
||||||
|
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
||||||
subscriptions.map.insert(token.clone(), tx);
|
let entry_uuid = uuid::Uuid::new_v4();
|
||||||
|
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
|
||||||
|
|
||||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||||
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, addr))
|
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok({
|
Ok({
|
||||||
@@ -257,11 +279,11 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
// Websockets server
|
// Websockets server
|
||||||
//
|
//
|
||||||
|
|
||||||
fn serialize(val: Value) -> Vec<u8> {
|
fn serialize(val: &Value) -> Vec<u8> {
|
||||||
use rmpv::encode::write_value;
|
use rmpv::encode::write_value;
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_value(&mut buf, &val).expect("Error encoding MsgPack");
|
write_value(&mut buf, val).expect("Error encoding MsgPack");
|
||||||
|
|
||||||
// Add size bytes at the start
|
// Add size bytes at the start
|
||||||
// Extracted from BinaryMessageFormat.js
|
// Extracted from BinaryMessageFormat.js
|
||||||
@@ -269,14 +291,15 @@ fn serialize(val: Value) -> Vec<u8> {
|
|||||||
let mut len_buf: Vec<u8> = Vec::new();
|
let mut len_buf: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut size_part = size & 0x7f;
|
#[expect(clippy::cast_possible_truncation, reason = "masked to 7 bits, fits u8")]
|
||||||
|
let mut size_part = (size & 0x7f) as u8;
|
||||||
size >>= 7;
|
size >>= 7;
|
||||||
|
|
||||||
if size > 0 {
|
if size > 0 {
|
||||||
size_part |= 0x80;
|
size_part |= 0x80;
|
||||||
}
|
}
|
||||||
|
|
||||||
len_buf.push(size_part as u8);
|
len_buf.push(size_part);
|
||||||
|
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
break;
|
break;
|
||||||
@@ -330,7 +353,7 @@ pub struct WebSocketUsers {
|
|||||||
impl WebSocketUsers {
|
impl WebSocketUsers {
|
||||||
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
||||||
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
||||||
for (_, sender) in user.iter() {
|
for (_, sender) in &user {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -339,7 +362,7 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: The last modified date needs to be updated before calling these methods
|
// NOTE: The last modified date needs to be updated before calling these methods
|
||||||
pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &mut DbConn) {
|
pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
@@ -359,15 +382,16 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &mut DbConn) {
|
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||||
let data = create_update(
|
let data = create_update(
|
||||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||||
UpdateType::LogOut,
|
UpdateType::LogOut,
|
||||||
acting_device_id.clone(),
|
acting_device_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
@@ -375,11 +399,11 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
push_logout(user, acting_device_id.clone(), conn).await;
|
push_logout(user, acting_device, conn).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, device: &Device, conn: &mut DbConn) {
|
pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
@@ -410,7 +434,7 @@ impl WebSocketUsers {
|
|||||||
user_ids: &[UserId],
|
user_ids: &[UserId],
|
||||||
device: &Device,
|
device: &Device,
|
||||||
collection_uuids: Option<Vec<CollectionId>>,
|
collection_uuids: Option<Vec<CollectionId>>,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
@@ -458,7 +482,7 @@ impl WebSocketUsers {
|
|||||||
send: &DbSend,
|
send: &DbSend,
|
||||||
user_ids: &[UserId],
|
user_ids: &[UserId],
|
||||||
device: &Device,
|
device: &Device,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
@@ -486,13 +510,7 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_auth_request(
|
pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) {
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
auth_request_uuid: &str,
|
|
||||||
device: &Device,
|
|
||||||
conn: &mut DbConn,
|
|
||||||
) {
|
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
@@ -516,7 +534,7 @@ impl WebSocketUsers {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
auth_request_id: &AuthRequestId,
|
auth_request_id: &AuthRequestId,
|
||||||
device: &Device,
|
device: &Device,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) {
|
) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
@@ -539,12 +557,39 @@ impl WebSocketUsers {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AnonymousWebSocketSubscriptions {
|
pub struct AnonymousWebSocketSubscriptions {
|
||||||
map: Arc<dashmap::DashMap<String, Sender<Message>>>,
|
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
|
||||||
|
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnonymousWebSocketSubscriptions {
|
impl AnonymousWebSocketSubscriptions {
|
||||||
|
/// Takes a connection slot for this address, returns false when it already reached the limit.
|
||||||
|
fn try_reserve(&self, addr: IpAddr) -> bool {
|
||||||
|
let mut count = self.connections.entry(addr).or_insert(0);
|
||||||
|
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*count += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases a slot taken by `try_reserve`.
|
||||||
|
fn release(&self, addr: IpAddr) {
|
||||||
|
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
|
||||||
|
*count = count.saturating_sub(1);
|
||||||
|
*count == 0
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
// Only remove once the guard above is dropped, otherwise this deadlocks.
|
||||||
|
if empty {
|
||||||
|
self.connections.remove_if(&addr, |_, count| *count == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_update(&self, token: &str, data: &[u8]) {
|
async fn send_update(&self, token: &str, data: &[u8]) {
|
||||||
if let Some(sender) = self.map.get(token).map(|v| v.clone()) {
|
// Clone the senders so the map isn't kept locked while sending.
|
||||||
|
let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
|
||||||
|
for (_, sender) in senders {
|
||||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||||
error!("Error sending WS update {e}");
|
error!("Error sending WS update {e}");
|
||||||
}
|
}
|
||||||
@@ -558,7 +603,7 @@ impl AnonymousWebSocketSubscriptions {
|
|||||||
let data = create_anonymous_update(
|
let data = create_anonymous_update(
|
||||||
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
||||||
UpdateType::AuthRequestResponse,
|
UpdateType::AuthRequestResponse,
|
||||||
user_id.clone(),
|
user_id,
|
||||||
);
|
);
|
||||||
self.send_update(auth_request_id, &data).await;
|
self.send_update(auth_request_id, &data).await;
|
||||||
}
|
}
|
||||||
@@ -588,22 +633,25 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id:
|
|||||||
V::Nil,
|
V::Nil,
|
||||||
"ReceiveMessage".into(),
|
"ReceiveMessage".into(),
|
||||||
V::Array(vec![V::Map(vec![
|
V::Array(vec![V::Map(vec![
|
||||||
("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)),
|
("ContextId".into(), acting_device_id.map_or(V::Nil, |v| v.to_string().into())),
|
||||||
("Type".into(), (ut as i32).into()),
|
("Type".into(), (ut as i32).into()),
|
||||||
("Payload".into(), payload.into()),
|
("Payload".into(), payload.into()),
|
||||||
])]),
|
])]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
serialize(value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: UserId) -> Vec<u8> {
|
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec<u8> {
|
||||||
use rmpv::Value as V;
|
use rmpv::Value as V;
|
||||||
|
|
||||||
let value = V::Array(vec![
|
let value = V::Array(vec![
|
||||||
1.into(),
|
1.into(),
|
||||||
V::Map(vec![]),
|
V::Map(vec![]),
|
||||||
V::Nil,
|
V::Nil,
|
||||||
|
// This word is misspelled, but upstream has this too
|
||||||
|
// https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
|
||||||
|
// https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
|
||||||
"AuthRequestResponseRecieved".into(),
|
"AuthRequestResponseRecieved".into(),
|
||||||
V::Array(vec![V::Map(vec![
|
V::Array(vec![V::Map(vec![
|
||||||
("Type".into(), (ut as i32).into()),
|
("Type".into(), (ut as i32).into()),
|
||||||
@@ -612,11 +660,11 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
])]),
|
])]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
serialize(value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ping() -> Vec<u8> {
|
fn create_ping() -> Vec<u8> {
|
||||||
serialize(Value::Array(vec![6.into()]))
|
serialize(&Value::Array(vec![6.into()]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
|
|||||||
+32
-34
@@ -1,21 +1,26 @@
|
|||||||
|
use std::{
|
||||||
|
sync::LazyLock,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
|
||||||
Method,
|
Method,
|
||||||
|
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, UpdateType},
|
||||||
db::models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId},
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
|
},
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
util::{format_date, get_uuid},
|
util::{format_date, get_uuid},
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthPushToken {
|
struct AuthPushToken {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -29,7 +34,7 @@ struct LocalAuthPushToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_auth_api_token() -> ApiResult<String> {
|
async fn get_auth_api_token() -> ApiResult<String> {
|
||||||
static API_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| {
|
static API_TOKEN: LazyLock<RwLock<LocalAuthPushToken>> = LazyLock::new(|| {
|
||||||
RwLock::new(LocalAuthPushToken {
|
RwLock::new(LocalAuthPushToken {
|
||||||
access_token: String::new(),
|
access_token: String::new(),
|
||||||
valid_until: Instant::now(),
|
valid_until: Instant::now(),
|
||||||
@@ -69,9 +74,9 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut api_token = API_TOKEN.write().await;
|
let mut api_token = API_TOKEN.write().await;
|
||||||
api_token.valid_until = Instant::now()
|
// Token valid for half the specified time
|
||||||
.checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
|
let half_expires_in = u64::from((json_pushtoken.expires_in / 2).max(0).cast_unsigned());
|
||||||
.unwrap();
|
api_token.valid_until = Instant::now().checked_add(Duration::from_secs(half_expires_in)).unwrap();
|
||||||
|
|
||||||
api_token.access_token = json_pushtoken.access_token;
|
api_token.access_token = json_pushtoken.access_token;
|
||||||
|
|
||||||
@@ -79,7 +84,7 @@ async fn get_auth_api_token() -> ApiResult<String> {
|
|||||||
Ok(api_token.access_token.clone())
|
Ok(api_token.access_token.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbConn) -> EmptyResult {
|
pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyResult {
|
||||||
if !CONFIG.push_enabled() || !device.is_push_device() {
|
if !CONFIG.push_enabled() || !device.is_push_device() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -123,14 +128,14 @@ pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbC
|
|||||||
err!(format!("An error occurred while proceeding registration of a device: {e}"));
|
err!(format!("An error occurred while proceeding registration of a device: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = device.save(conn).await {
|
if let Err(e) = device.save(true, conn).await {
|
||||||
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
|
err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unregister_push_device(push_id: &Option<PushId>) -> EmptyResult {
|
pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
||||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -152,11 +157,11 @@ pub async fn unregister_push_device(push_id: &Option<PushId>) -> EmptyResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &mut crate::db::DbConn) {
|
pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &DbConn) {
|
||||||
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
||||||
if cipher.organization_uuid.is_some() {
|
if cipher.organization_uuid.is_some() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let Some(user_id) = &cipher.user_uuid else {
|
let Some(user_id) = &cipher.user_uuid else {
|
||||||
debug!("Cipher has no uuid");
|
debug!("Cipher has no uuid");
|
||||||
return;
|
return;
|
||||||
@@ -183,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &mut crate::db::DbConn) {
|
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
|
|
||||||
|
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"organizationId": (),
|
"organizationId": (),
|
||||||
"deviceId": acting_device_id,
|
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||||
"identifier": acting_device_id,
|
"identifier": acting_device.map(|d| &d.uuid),
|
||||||
"type": UpdateType::LogOut as i32,
|
"type": UpdateType::LogOut as i32,
|
||||||
"payload": {
|
"payload": {
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
@@ -203,7 +206,7 @@ pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &mut crate::db::DbConn) {
|
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
@@ -221,7 +224,7 @@ pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option<Pu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device, conn: &mut crate::db::DbConn) {
|
pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&folder.user_uuid, conn).await {
|
if Device::check_user_has_push_device(&folder.user_uuid, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": folder.user_uuid,
|
"userId": folder.user_uuid,
|
||||||
@@ -240,9 +243,10 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &mut crate::db::DbConn) {
|
pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) {
|
||||||
if let Some(s) = &send.user_uuid {
|
if let Some(s) = &send.user_uuid
|
||||||
if Device::check_user_has_push_device(s, conn).await {
|
&& Device::check_user_has_push_device(s, conn).await
|
||||||
|
{
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": send.user_uuid,
|
"userId": send.user_uuid,
|
||||||
"organizationId": null,
|
"organizationId": null,
|
||||||
@@ -258,7 +262,6 @@ pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn
|
|||||||
"installationId": null
|
"installationId": null
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_to_push_relay(notification_data: Value) {
|
async fn send_to_push_relay(notification_data: Value) {
|
||||||
@@ -293,10 +296,10 @@ async fn send_to_push_relay(notification_data: Value) {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
error!("An error occurred while sending a send update to the push relay: {e}");
|
error!("An error occurred while sending a send update to the push relay: {e}");
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &mut crate::db::DbConn) {
|
pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(user_id, conn).await {
|
if Device::check_user_has_push_device(user_id, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user_id,
|
"userId": user_id,
|
||||||
@@ -314,12 +317,7 @@ pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_auth_response(
|
pub async fn push_auth_response(user_id: &UserId, auth_request_id: &AuthRequestId, device: &Device, conn: &DbConn) {
|
||||||
user_id: &UserId,
|
|
||||||
auth_request_id: &AuthRequestId,
|
|
||||||
device: &Device,
|
|
||||||
conn: &mut crate::db::DbConn,
|
|
||||||
) {
|
|
||||||
if Device::check_user_has_push_device(user_id, conn).await {
|
if Device::check_user_has_push_device(user_id, conn).await {
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
"userId": user_id,
|
"userId": user_id,
|
||||||
|
|||||||
+44
-13
@@ -1,21 +1,24 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
|
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
Catcher, Route,
|
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{core::now, ApiResult, EmptyResult},
|
CONFIG,
|
||||||
|
api::{ApiResult, EmptyResult, core::now},
|
||||||
auth::decode_file_download,
|
auth::decode_file_download,
|
||||||
db::models::{AttachmentId, CipherId},
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{AttachmentId, CipherId},
|
||||||
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
util::Cached,
|
util::Cached,
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
@@ -23,12 +26,20 @@ pub fn routes() -> Vec<Route> {
|
|||||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||||
if CONFIG.web_vault_enabled() {
|
if CONFIG.web_vault_enabled() {
|
||||||
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
|
routes.append(&mut routes![
|
||||||
|
web_index,
|
||||||
|
web_index_direct,
|
||||||
|
web_index_head,
|
||||||
|
app_id,
|
||||||
|
apple_app_site_association,
|
||||||
|
web_files,
|
||||||
|
vaultwarden_css
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if CONFIG.reload_templates() {
|
if CONFIG.reload_templates() {
|
||||||
routes.append(&mut routes![_static_files_dev]);
|
routes.append(&mut routes![static_files_dev]);
|
||||||
}
|
}
|
||||||
|
|
||||||
routes
|
routes
|
||||||
@@ -60,9 +71,12 @@ fn vaultwarden_css() -> Cached<Css<String>> {
|
|||||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||||
"mail_enabled": CONFIG.mail_enabled(),
|
"mail_enabled": CONFIG.mail_enabled(),
|
||||||
"sends_allowed": CONFIG.sends_allowed(),
|
"sends_allowed": CONFIG.sends_allowed(),
|
||||||
|
"remember_2fa_disabled": CONFIG.disable_2fa_remember(),
|
||||||
|
"password_hints_allowed": CONFIG.password_hints_allowed(),
|
||||||
"signup_disabled": CONFIG.is_signup_disabled(),
|
"signup_disabled": CONFIG.is_signup_disabled(),
|
||||||
"sso_disabled": !CONFIG.sso_enabled(),
|
"sso_enabled": CONFIG.sso_enabled(),
|
||||||
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
||||||
|
"webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(),
|
||||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,6 +171,24 @@ fn app_id() -> Cached<(ContentType, Json<Value>)> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/.well-known/apple-app-site-association")]
|
||||||
|
fn apple_app_site_association() -> Cached<(ContentType, Json<Value>)> {
|
||||||
|
Cached::long(
|
||||||
|
(
|
||||||
|
ContentType::JSON,
|
||||||
|
Json(json!({
|
||||||
|
"webcredentials": {
|
||||||
|
"apps": [
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden",
|
||||||
|
"LTZ2PFU5D6.com.8bit.bitwarden.beta"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
|
||||||
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||||
@@ -175,7 +207,6 @@ async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
||||||
use crate::db::DbConn;
|
|
||||||
#[get("/alive")]
|
#[get("/alive")]
|
||||||
fn alive(_conn: DbConn) -> Json<String> {
|
fn alive(_conn: DbConn) -> Json<String> {
|
||||||
now()
|
now()
|
||||||
@@ -194,7 +225,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
warn!("LOADING STATIC FILES FROM DISK");
|
warn!("LOADING STATIC FILES FROM DISK");
|
||||||
let file = filename.to_str().unwrap_or_default();
|
let file = filename.to_str().unwrap_or_default();
|
||||||
let ext = filename.extension().unwrap_or_default();
|
let ext = filename.extension().unwrap_or_default();
|
||||||
@@ -207,7 +238,7 @@ pub async fn _static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
|
|
||||||
if let Ok(path) = path {
|
if let Ok(path) = path {
|
||||||
return NamedFile::open(path).await.ok();
|
return NamedFile::open(path).await.ok();
|
||||||
};
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +268,8 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
|
|||||||
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
|
||||||
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
|
||||||
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
|
||||||
"jquery-3.7.1.slim.js" => {
|
"jquery-4.0.0.slim.js" => {
|
||||||
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.7.1.slim.js")))
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
|
||||||
}
|
}
|
||||||
_ => err!(format!("Static file not found: {filename}")),
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-129
@@ -1,60 +1,78 @@
|
|||||||
// JWT Handling
|
#[path = "auth/send.rs"]
|
||||||
|
pub mod send;
|
||||||
|
pub type SendTokens = send::SendTokens;
|
||||||
|
pub type SendHeaders = send::SendHeaders;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{LazyLock, OnceLock},
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, TimeDelta, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
use ipnet::IpNet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use once_cell::sync::{Lazy, OnceCell};
|
|
||||||
use openssl::rsa::Rsa;
|
use openssl::rsa::Rsa;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::{de::DeserializeOwned, ser::Serialize};
|
||||||
use serde::ser::Serialize;
|
|
||||||
use std::{env, net::IpAddr};
|
use rocket::{
|
||||||
|
outcome::try_outcome,
|
||||||
|
request::{FromRequest, Outcome, Request},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
CONFIG,
|
||||||
api::ApiResult,
|
api::ApiResult,
|
||||||
config::PathType,
|
config::PathType,
|
||||||
db::models::{
|
db::{
|
||||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
DbConn,
|
||||||
OrganizationId, SendFileId, SendId, UserId,
|
models::{
|
||||||
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
|
||||||
|
SendId, User, UserId, UserStampException,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
sso, CONFIG,
|
sso,
|
||||||
};
|
};
|
||||||
|
|
||||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||||
|
|
||||||
// Limit when BitWarden consider the token as expired
|
// Limit when BitWarden consider the token as expired
|
||||||
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
|
pub static BW_EXPIRATION: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_minutes(5).unwrap());
|
||||||
|
|
||||||
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
|
pub static DEFAULT_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(30).unwrap());
|
||||||
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap());
|
pub static MOBILE_REFRESH_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_days(90).unwrap());
|
||||||
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
pub static DEFAULT_ACCESS_VALIDITY: LazyLock<TimeDelta> = LazyLock::new(|| TimeDelta::try_hours(2).unwrap());
|
||||||
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
static JWT_HEADER: LazyLock<Header> = LazyLock::new(|| Header::new(JWT_ALGORITHM));
|
||||||
|
|
||||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
pub static JWT_LOGIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
static JWT_INVITE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: LazyLock<String> =
|
||||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
LazyLock::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
||||||
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
static JWT_DELETE_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
||||||
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
static JWT_VERIFYEMAIL_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
static JWT_ADMIN_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||||
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
|
static JWT_SEND_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|send", CONFIG.domain_origin()));
|
||||||
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
|
static JWT_ORG_API_KEY_ISSUER: LazyLock<String> =
|
||||||
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
LazyLock::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
|
||||||
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
|
||||||
|
LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
||||||
|
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
|
||||||
|
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
||||||
|
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
|
||||||
|
|
||||||
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
|
||||||
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
|
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn initialize_keys() -> Result<(), Error> {
|
pub async fn initialize_keys() -> Result<(), Error> {
|
||||||
use std::io::Error;
|
use std::io::Error as IoError;
|
||||||
|
|
||||||
let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key())
|
let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
|
||||||
.file_name()
|
.ok_or_else(|| IoError::other("Private RSA key path missing filename"))?;
|
||||||
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?
|
|
||||||
.to_str()
|
|
||||||
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(PathType::RsaKey).map_err(Error::other)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(IoError::other)?;
|
||||||
|
|
||||||
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
let priv_key_buffer = match operator.read(&rsa_key_filename).await {
|
||||||
Ok(buffer) => Some(buffer),
|
Ok(buffer) => Some(buffer),
|
||||||
@@ -154,6 +172,10 @@ pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error
|
|||||||
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_2fa_remember(token: &str) -> Result<TwoFactorRememberClaims, Error> {
|
||||||
|
decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct LoginJwtClaims {
|
pub struct LoginJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -219,7 +241,7 @@ impl LoginJwtClaims {
|
|||||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
|
||||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||||
warn!("Raise access_token lifetime to more than 5min.")
|
warn!("Raise access_token lifetime to more than 5min.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the JWT claims struct, to send to the client
|
// Create the JWT claims struct, to send to the client
|
||||||
@@ -246,7 +268,7 @@ impl LoginJwtClaims {
|
|||||||
sstamp: user.security_stamp.clone(),
|
sstamp: user.security_stamp.clone(),
|
||||||
device: device.uuid.clone(),
|
device: device.uuid.clone(),
|
||||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
client_id: client_id.unwrap_or("undefined".to_owned()),
|
||||||
scope,
|
scope,
|
||||||
amr: vec!["Application".into()],
|
amr: vec!["Application".into()],
|
||||||
}
|
}
|
||||||
@@ -434,6 +456,31 @@ pub fn generate_register_verify_claims(email: String, name: Option<String>, veri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TwoFactorRememberClaims {
|
||||||
|
// Not before
|
||||||
|
pub nbf: i64,
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
// Subject
|
||||||
|
pub sub: DeviceId,
|
||||||
|
// UserId
|
||||||
|
pub user_uuid: UserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> TwoFactorRememberClaims {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
TwoFactorRememberClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||||
|
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||||
|
sub: device_uuid,
|
||||||
|
user_uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -446,6 +493,16 @@ pub struct BasicJwtClaims {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BasicJwtClaims {
|
||||||
|
pub fn expires_in(&self) -> i64 {
|
||||||
|
self.exp - Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> String {
|
||||||
|
encode_jwt(&self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
@@ -457,7 +514,7 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_verify_email_claims(user_id: UserId) -> BasicJwtClaims {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
@@ -474,7 +531,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
|||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_string(),
|
sub: "admin_panel".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,16 +548,6 @@ pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtC
|
|||||||
//
|
//
|
||||||
// Bearer token authentication
|
// Bearer token authentication
|
||||||
//
|
//
|
||||||
use rocket::{
|
|
||||||
outcome::try_outcome,
|
|
||||||
request::{FromRequest, Outcome, Request},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::db::{
|
|
||||||
models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException},
|
|
||||||
DbConn,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
}
|
}
|
||||||
@@ -516,7 +563,7 @@ impl<'r> FromRequest<'r> for Host {
|
|||||||
let host = if CONFIG.domain_set() {
|
let host = if CONFIG.domain_set() {
|
||||||
CONFIG.domain()
|
CONFIG.domain()
|
||||||
} else if let Some(referer) = headers.get_one("Referer") {
|
} else if let Some(referer) = headers.get_one("Referer") {
|
||||||
referer.to_string()
|
referer.to_owned()
|
||||||
} else {
|
} else {
|
||||||
// Try to guess from the headers
|
// Try to guess from the headers
|
||||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||||
@@ -552,13 +599,15 @@ impl<'r> FromRequest<'r> for ClientHeaders {
|
|||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
|
|
||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
// When unknown or unable to parse, return 'UnknownBrowser'
|
||||||
let device_type: i32 =
|
let device_type: i32 = request
|
||||||
request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14);
|
.headers()
|
||||||
|
.get_one("device-type")
|
||||||
|
.and_then(|d| d.parse().ok())
|
||||||
|
.unwrap_or(DeviceType::UnknownBrowser as i32);
|
||||||
|
|
||||||
Outcome::Success(ClientHeaders {
|
Outcome::Success(ClientHeaders {
|
||||||
device_type,
|
device_type,
|
||||||
@@ -582,18 +631,19 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
|
|
||||||
let host = try_outcome!(Host::from_request(request).await).host;
|
let host = try_outcome!(Host::from_request(request).await).host;
|
||||||
let ip = match ClientIp::from_request(request).await {
|
let Outcome::Success(ip) = ClientIp::from_request(request).await else {
|
||||||
Outcome::Success(ip) => ip,
|
err_handler!("Error getting Client IP")
|
||||||
_ => err_handler!("Error getting Client IP"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get access_token
|
// Get access_token
|
||||||
let access_token: &str = match headers.get_one("Authorization") {
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
Some(a) => match a.rsplit("Bearer ").next() {
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
Some(split) => split,
|
split
|
||||||
None => err_handler!("No access token provided"),
|
} else {
|
||||||
},
|
err_handler!("No access token provided")
|
||||||
None => err_handler!("No access token provided"),
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check JWT token is valid and get device and user from it
|
// Check JWT token is valid and get device and user from it
|
||||||
@@ -604,16 +654,15 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
let device_id = claims.device;
|
let device_id = claims.device;
|
||||||
let user_id = claims.sub;
|
let user_id = claims.sub;
|
||||||
|
|
||||||
let mut conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &mut conn).await else {
|
let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else {
|
||||||
err_handler!("Invalid device id")
|
err_handler!("Invalid device id")
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(user) = User::find_by_uuid(&user_id, &mut conn).await else {
|
let Some(user) = User::find_by_uuid(&user_id, &conn).await else {
|
||||||
err_handler!("Device has no user associated")
|
err_handler!("Device has no user associated")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -633,11 +682,11 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
// This prevents checking this stamp exception for new requests.
|
// This prevents checking this stamp exception for new requests.
|
||||||
let mut user = user;
|
let mut user = user;
|
||||||
user.reset_stamp_exception();
|
user.reset_stamp_exception();
|
||||||
if let Err(e) = user.save(&mut conn).await {
|
if let Err(e) = user.save(&conn).await {
|
||||||
error!("Error updating user: {e:#?}");
|
error!("Error updating user: {e:#?}");
|
||||||
}
|
}
|
||||||
err_handler!("Stamp exception is expired")
|
err_handler!("Stamp exception is expired")
|
||||||
} else if !stamp_exception.routes.contains(¤t_route.to_string()) {
|
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
||||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
||||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
} else if stamp_exception.security_stamp != claims.sstamp {
|
||||||
err_handler!("Invalid security stamp for matched stamp exception")
|
err_handler!("Invalid security stamp for matched stamp exception")
|
||||||
@@ -668,10 +717,9 @@ pub struct OrgHeaders {
|
|||||||
|
|
||||||
impl OrgHeaders {
|
impl OrgHeaders {
|
||||||
fn is_member(&self) -> bool {
|
fn is_member(&self) -> bool {
|
||||||
// NOTE: we don't care about MembershipStatus at the moment because this is only used
|
// Only allow not revoked members, we can not use the Confirmed status here
|
||||||
// where an invited, accepted or confirmed user is expected if this ever changes or
|
// as some endpoints can be triggered by invited users during joining
|
||||||
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
|
self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
|
||||||
self.membership_type >= MembershipType::User
|
|
||||||
}
|
}
|
||||||
fn is_confirmed_and_admin(&self) -> bool {
|
fn is_confirmed_and_admin(&self) -> bool {
|
||||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
||||||
@@ -684,6 +732,36 @@ impl OrgHeaders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||||
|
// but there are cases where it is a query value.
|
||||||
|
// First check the path, if this is not a valid uuid, try the query values.
|
||||||
|
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
|
||||||
|
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||||
|
Some(org_id)
|
||||||
|
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||||
|
Some(org_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special Guard to ensure that there is an organization id present
|
||||||
|
// If there is no org id trigger the Outcome::Forward.
|
||||||
|
// This is useful for endpoints which work for both organization and personal vaults, like purge.
|
||||||
|
pub struct OrgIdGuard;
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for OrgIdGuard {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
match get_org_id(request) {
|
||||||
|
Some(_) => Outcome::Success(OrgIdGuard),
|
||||||
|
None => Outcome::Forward(rocket::http::Status::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for OrgHeaders {
|
impl<'r> FromRequest<'r> for OrgHeaders {
|
||||||
type Error = &'static str;
|
type Error = &'static str;
|
||||||
@@ -691,28 +769,17 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = try_outcome!(Headers::from_request(request).await);
|
let headers = try_outcome!(Headers::from_request(request).await);
|
||||||
|
|
||||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
// Extract the org_id from the request
|
||||||
// but there are cases where it is a query value.
|
let url_org_id = get_org_id(request);
|
||||||
// First check the path, if this is not a valid uuid, try the query values.
|
|
||||||
let url_org_id: Option<OrganizationId> = {
|
|
||||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
|
||||||
Some(org_id.clone())
|
|
||||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
|
||||||
Some(org_id.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match url_org_id {
|
match url_org_id {
|
||||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||||
let mut conn = match DbConn::from_request(request).await {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
Outcome::Success(conn) => conn,
|
err_handler!("Error getting DB")
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let user = headers.user;
|
let user = headers.user;
|
||||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await else {
|
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await else {
|
||||||
err_handler!("The current user isn't member of the organization");
|
err_handler!("The current user isn't member of the organization");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -780,17 +847,17 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
|||||||
// but there could be cases where it is a query value.
|
// but there could be cases where it is a query value.
|
||||||
// First check the path, if this is not a valid uuid, try the query values.
|
// First check the path, if this is not a valid uuid, try the query values.
|
||||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
if let Some(Ok(col_id)) = request.param::<String>(3)
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
|
{
|
||||||
return Some(col_id.into());
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId")
|
||||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
&& uuid::Uuid::parse_str(&col_id).is_ok()
|
||||||
|
{
|
||||||
return Some(col_id.into());
|
return Some(col_id.into());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -813,18 +880,16 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
|||||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||||
if headers.is_confirmed_and_manager() {
|
if headers.is_confirmed_and_manager() {
|
||||||
match get_col_id(request) {
|
if let Some(col_id) = get_col_id(request) {
|
||||||
Some(col_id) => {
|
let Outcome::Success(conn) = DbConn::from_request(request).await else {
|
||||||
let mut conn = match DbConn::from_request(request).await {
|
err_handler!("Error getting DB")
|
||||||
Outcome::Success(conn) => conn,
|
|
||||||
_ => err_handler!("Error getting DB"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !Collection::can_access_collection(&headers.membership, &col_id, &mut conn).await {
|
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
|
||||||
err_handler!("The current user isn't a manager for this collection")
|
err_handler!("The current user isn't a manager for this collection")
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
_ => err_handler!("Error getting the collection id"),
|
err_handler!("Error getting the collection id")
|
||||||
}
|
}
|
||||||
|
|
||||||
Outcome::Success(Self {
|
Outcome::Success(Self {
|
||||||
@@ -896,14 +961,14 @@ impl ManagerHeaders {
|
|||||||
pub async fn from_loose(
|
pub async fn from_loose(
|
||||||
h: ManagerHeadersLoose,
|
h: ManagerHeadersLoose,
|
||||||
collections: &Vec<CollectionId>,
|
collections: &Vec<CollectionId>,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> Result<ManagerHeaders, Error> {
|
) -> Result<ManagerHeaders, Error> {
|
||||||
for col_id in collections {
|
for col_id in collections {
|
||||||
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
||||||
err!("Collection Id is malformed!");
|
err!("Collection Id is malformed!");
|
||||||
}
|
}
|
||||||
if !Collection::can_access_collection(&h.membership, col_id, conn).await {
|
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await {
|
||||||
err!("You don't have access to all collections!");
|
err!("Collection not found", "The current user isn't a manager for this collection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,17 +1050,49 @@ impl From<OrgMemberHeaders> for Headers {
|
|||||||
//
|
//
|
||||||
// Client IP address detection
|
// Client IP address detection
|
||||||
//
|
//
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct ClientIp {
|
pub struct ClientIp {
|
||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
|
||||||
|
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
|
||||||
|
let entry = entry.trim();
|
||||||
|
match entry.parse::<IpNet>() {
|
||||||
|
Ok(net) => Some(net),
|
||||||
|
// Without a prefix length it is a single address, which is a valid way to write this.
|
||||||
|
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
|
||||||
|
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
|
||||||
|
let trusted = CONFIG.ip_header_trusted_proxies();
|
||||||
|
let trusted = trusted.trim();
|
||||||
|
if trusted.eq_ignore_ascii_case("all") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(remote) = remote else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
|
||||||
|
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
|
||||||
|
let remote = remote.to_canonical();
|
||||||
|
if trusted.eq_ignore_ascii_case("local") {
|
||||||
|
return !crate::util::is_global(remote);
|
||||||
|
}
|
||||||
|
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl<'r> FromRequest<'r> for ClientIp {
|
impl<'r> FromRequest<'r> for ClientIp {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
let ip = if CONFIG._ip_header_enabled() {
|
let remote = req.remote().map(|r| r.ip());
|
||||||
|
|
||||||
|
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
|
||||||
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
|
||||||
match ip.find(',') {
|
match ip.find(',') {
|
||||||
Some(idx) => &ip[..idx],
|
Some(idx) => &ip[..idx],
|
||||||
@@ -1006,10 +1103,13 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
.ok()
|
.ok()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
|
||||||
|
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
|
||||||
|
}
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
|
||||||
|
|
||||||
Outcome::Success(ClientIp {
|
Outcome::Success(ClientIp {
|
||||||
ip,
|
ip,
|
||||||
@@ -1017,6 +1117,7 @@ impl<'r> FromRequest<'r> for ClientIp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
pub struct Secure {
|
pub struct Secure {
|
||||||
pub https: bool,
|
pub https: bool,
|
||||||
}
|
}
|
||||||
@@ -1102,15 +1203,14 @@ pub enum AuthMethod {
|
|||||||
impl AuthMethod {
|
impl AuthMethod {
|
||||||
pub fn scope(&self) -> String {
|
pub fn scope(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
AuthMethod::OrgApiKey => "api.organization".to_owned(),
|
||||||
AuthMethod::Password => "api offline_access".to_string(),
|
AuthMethod::UserApiKey => "api".to_owned(),
|
||||||
AuthMethod::Sso => "api offline_access".to_string(),
|
AuthMethod::Password | AuthMethod::Sso => "api offline_access".to_owned(),
|
||||||
AuthMethod::UserApiKey => "api".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scope_vec(&self) -> Vec<String> {
|
pub fn scope_vec(&self) -> Vec<String> {
|
||||||
self.scope().split_whitespace().map(str::to_string).collect()
|
self.scope().split_whitespace().map(str::to_owned).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||||
@@ -1174,7 +1274,7 @@ impl AuthTokens {
|
|||||||
|
|
||||||
let access_claims = LoginJwtClaims::default(device, user, &sub, client_id);
|
let access_claims = LoginJwtClaims::default(device, user, &sub, client_id);
|
||||||
|
|
||||||
let validity = if DeviceType::is_mobile(&device.atype) {
|
let validity = if device.is_mobile() {
|
||||||
*MOBILE_REFRESH_VALIDITY
|
*MOBILE_REFRESH_VALIDITY
|
||||||
} else {
|
} else {
|
||||||
*DEFAULT_REFRESH_VALIDITY
|
*DEFAULT_REFRESH_VALIDITY
|
||||||
@@ -1200,28 +1300,26 @@ pub async fn refresh_tokens(
|
|||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
refresh_token: &str,
|
refresh_token: &str,
|
||||||
client_id: Option<String>,
|
client_id: Option<String>,
|
||||||
conn: &mut DbConn,
|
conn: &DbConn,
|
||||||
) -> ApiResult<(Device, AuthTokens)> {
|
) -> ApiResult<(Device, AuthTokens)> {
|
||||||
let refresh_claims = match decode_refresh(refresh_token) {
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
|
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
|
||||||
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
err_silent!("Invalid refresh token")
|
||||||
}
|
}
|
||||||
Ok(claims) => claims,
|
Ok(claims) => claims,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get device by refresh token
|
// Get device by refresh token
|
||||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
let Some(mut device) = Device::find_by_refresh_token(&refresh_claims.device_token, conn).await else {
|
||||||
None => err!("Invalid refresh token"),
|
err!("Invalid refresh token")
|
||||||
Some(device) => device,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to update `updated_at`.
|
// Save to update `updated_at`.
|
||||||
device.save(conn).await?;
|
device.save(true, conn).await?;
|
||||||
|
|
||||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
let Some(user) = User::find_by_uuid(&device.user_uuid, conn).await else {
|
||||||
None => err!("Impossible to find user"),
|
err!("Impossible to find user")
|
||||||
Some(user) => user,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth_tokens = match refresh_claims.sub {
|
let auth_tokens = match refresh_claims.sub {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
use chrono::{TimeDelta, Utc};
|
||||||
|
|
||||||
|
use rocket::request::{FromRequest, Outcome, Request};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiResult,
|
||||||
|
auth,
|
||||||
|
auth::{BasicJwtClaims, ClientIp},
|
||||||
|
db::{
|
||||||
|
DbConn,
|
||||||
|
models::{Send, SendId},
|
||||||
|
},
|
||||||
|
error::{Error, ErrorKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
BasicJwtClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
|
iss: auth::JWT_SEND_ISSUER.to_string(),
|
||||||
|
sub: format!("{send_id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SendTokens {
|
||||||
|
pub access_claims: BasicJwtClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendTokens {
|
||||||
|
pub fn as_send_id(access_id: &str) -> Option<SendId> {
|
||||||
|
data_encoding::BASE64URL_NOPAD
|
||||||
|
.decode(access_id.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"access_token": self.access_claims.token(),
|
||||||
|
"expires_in": self.access_claims.expires_in(),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"scope": "api.send.access",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_request",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
|
||||||
|
let err = json!({
|
||||||
|
"kind": "expected_server",
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"send_access_error_type": error_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_tokens(
|
||||||
|
access_id: &str,
|
||||||
|
password: Option<String>,
|
||||||
|
ip: &ClientIp,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> ApiResult<SendTokens> {
|
||||||
|
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||||
|
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(max_access_count) = send.max_access_count
|
||||||
|
&& send.access_count >= max_access_count
|
||||||
|
{
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expiration) = send.expiration_date
|
||||||
|
&& Utc::now().naive_utc() >= expiration
|
||||||
|
{
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if Utc::now().naive_utc() >= send.deletion_date {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send.disabled {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if send.password_hash.is_some() {
|
||||||
|
match password {
|
||||||
|
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
|
||||||
|
Some(_) => {
|
||||||
|
return Self::invalid_error(
|
||||||
|
&format!("Send {send_id}, Invalid password from {}", ip.ip),
|
||||||
|
"password_hash_b64_invalid",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => return Self::expected_error("Password required", "password_hash_b64_required"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send.register_access(conn).await? {
|
||||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
access_claims: generate_send_access_claims(&send_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SendHeaders {
|
||||||
|
pub send_id: SendId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for SendHeaders {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let headers = request.headers();
|
||||||
|
|
||||||
|
// Get access_token
|
||||||
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
|
||||||
|
if let Some(split) = a.rsplit("Bearer ").next() {
|
||||||
|
split
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err_handler!("No access token provided")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check JWT token is valid and get send_id
|
||||||
|
let Ok(claims) = auth::decode_send(access_token) else {
|
||||||
|
err_handler!("Invalid claim")
|
||||||
|
};
|
||||||
|
|
||||||
|
Outcome::Success(SendHeaders {
|
||||||
|
send_id: claims.sub.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+476
-361
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -48,20 +48,20 @@ pub fn get_random_bytes<const N: usize>() -> [u8; N] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode random bytes using the provided function.
|
/// Encode random bytes using the provided function.
|
||||||
pub fn encode_random_bytes<const N: usize>(e: Encoding) -> String {
|
pub fn encode_random_bytes<const N: usize>(e: &Encoding) -> String {
|
||||||
e.encode(&get_random_bytes::<N>())
|
e.encode(&get_random_bytes::<N>())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a random string over a specified alphabet.
|
/// Generates a random string over a specified alphabet.
|
||||||
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
||||||
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
||||||
use rand::Rng;
|
use rand::RngExt;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|
||||||
(0..num_chars)
|
(0..num_chars)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
let i = rng.random_range(0..alphabet.len());
|
let i = rng.random_range(0..alphabet.len());
|
||||||
alphabet[i] as char
|
char::from(alphabet[i])
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ pub fn get_random_string_alphanum(num_chars: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_id<const N: usize>() -> String {
|
pub fn generate_id<const N: usize>() -> String {
|
||||||
encode_random_bytes::<N>(HEXLOWER)
|
encode_random_bytes::<N>(&HEXLOWER)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_send_file_id() -> String {
|
pub fn generate_send_file_id() -> String {
|
||||||
@@ -113,3 +113,10 @@ pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
|
|||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
a.as_ref().ct_eq(b.as_ref()).into()
|
a.as_ref().ct_eq(b.as_ref()).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// SHA256
|
||||||
|
//
|
||||||
|
pub fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
HEXLOWER.encode(digest::digest(&digest::SHA256, data).as_ref())
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user