mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-09-10 18:55:57 +03:00
Compare commits
95 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
a523c82f5f | ||
|
4d6d3443ae | ||
|
9cd400db6c | ||
|
fd51230044 | ||
|
45e5f06b86 | ||
|
620ad92331 | ||
|
c9860af11c | ||
|
d7adce97df | ||
|
71b3d3c818 | ||
|
da3701c0cf | ||
|
96813b1317 | ||
|
b0b953f348 | ||
|
cdfdc6ff4f | ||
|
2393c3f3c0 | ||
|
0d16b38a68 | ||
|
ff33534c07 | ||
|
adb21d5c1a | ||
|
e927b8aa5e | ||
|
ba48ca68fc | ||
|
294b429436 | ||
|
37c14c3c69 | ||
|
d0581da638 | ||
|
38aad4f7be | ||
|
20d9e885bf | ||
|
2f20ad86f9 | ||
|
33bae5fbe9 | ||
|
f60502a17e | ||
|
13f4b66e62 | ||
|
c967d0ddc1 | ||
|
ae6ed0ece8 | ||
|
b7c254eb30 | ||
|
a47b484172 | ||
|
65629a99f0 | ||
|
49c5dec9b6 | ||
|
cd195ff243 | ||
|
e3541763fd | ||
|
f0efec7c96 | ||
|
040e2a7bb0 | ||
|
d184c8f08c | ||
|
7d6dec6413 | ||
|
de01111082 | ||
|
0bd8f607cb | ||
|
21efc0800d | ||
|
1031c2e286 | ||
|
1bf85201e7 | ||
|
6ceed9284d | ||
|
25d99e3506 | ||
|
dca14285fd | ||
|
66baa5e7d8 | ||
|
248e561b3f | ||
|
55623ad9c6 | ||
|
e9acd8bd3c | ||
|
544b7229e8 | ||
|
978f009293 | ||
|
92f1530e96 | ||
|
2b824e8096 | ||
|
059661be48 | ||
|
0f3f97cc76 | ||
|
aa0fe7785a | ||
|
65d11a9720 | ||
|
c722006385 | ||
|
aaab7f9640 | ||
|
cbdb5657f1 | ||
|
669b9db758 | ||
|
3466a8040e | ||
|
7d47155d83 | ||
|
9e26014b4d | ||
|
339612c917 | ||
|
9eebbf3b9f | ||
|
b557c11724 | ||
|
a1204cc935 | ||
|
1ea511cbfc | ||
|
2e6a6fa39f | ||
|
e7d5c17ff7 | ||
|
a7be8fab9b | ||
|
39d4d31080 | ||
|
c28246cf34 | ||
|
d7df0ad79e | ||
|
7c8ba0c232 | ||
|
d335187172 | ||
|
f858523d92 | ||
|
529c39c6c5 | ||
|
b428481ac0 | ||
|
b4b2701905 | ||
|
de66e56b6c | ||
|
ecfebaf3c7 | ||
|
0e53f58288 | ||
|
bc7ceb2ee3 | ||
|
b27e6e30c9 | ||
|
505b30eec2 | ||
|
54bfcb8bc3 | ||
|
035f694d2f | ||
|
a4ab014ade | ||
|
6fedfceaa9 | ||
|
8e8483481f |
@@ -1,40 +1,15 @@
|
||||
# Local build artifacts
|
||||
target
|
||||
// Ignore everything
|
||||
*
|
||||
|
||||
# Data folder
|
||||
data
|
||||
|
||||
# Misc
|
||||
.env
|
||||
.env.template
|
||||
.gitattributes
|
||||
.gitignore
|
||||
rustfmt.toml
|
||||
|
||||
# IDE files
|
||||
.vscode
|
||||
.idea
|
||||
.editorconfig
|
||||
*.iml
|
||||
|
||||
# Documentation
|
||||
.github
|
||||
*.md
|
||||
*.txt
|
||||
*.yml
|
||||
*.yaml
|
||||
|
||||
# Docker
|
||||
hooks
|
||||
tools
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker/**
|
||||
// Allow what is needed
|
||||
!.git
|
||||
!docker/healthcheck.sh
|
||||
!docker/start.sh
|
||||
!migrations
|
||||
!src
|
||||
|
||||
# Web vault
|
||||
web-vault
|
||||
|
||||
# Vaultwarden Resources
|
||||
resources
|
||||
!build.rs
|
||||
!Cargo.lock
|
||||
!Cargo.toml
|
||||
!rustfmt.toml
|
||||
!rust-toolchain.toml
|
||||
|
@@ -92,15 +92,20 @@
|
||||
##########################
|
||||
|
||||
## Enables push notifications (requires key and id from https://bitwarden.com/host)
|
||||
## If you choose "European Union" Data Region, uncomment PUSH_RELAY_URI and PUSH_IDENTITY_URI then replace .com by .eu
|
||||
## Details about mobile client push notification:
|
||||
## - https://github.com/dani-garcia/vaultwarden/wiki/Enabling-Mobile-Client-push-notification
|
||||
# PUSH_ENABLED=false
|
||||
# PUSH_INSTALLATION_ID=CHANGEME
|
||||
# PUSH_INSTALLATION_KEY=CHANGEME
|
||||
## Don't change this unless you know what you're doing.
|
||||
|
||||
# WARNING: Do not modify the following settings unless you fully understand their implications!
|
||||
# Default Push Relay and Identity URIs
|
||||
# PUSH_RELAY_URI=https://push.bitwarden.com
|
||||
# PUSH_IDENTITY_URI=https://identity.bitwarden.com
|
||||
# European Union Data Region Settings
|
||||
# If you have selected "European Union" as your data region, use the following URIs instead.
|
||||
# PUSH_RELAY_URI=https://api.bitwarden.eu
|
||||
# PUSH_IDENTITY_URI=https://identity.bitwarden.eu
|
||||
|
||||
#####################
|
||||
### Schedule jobs ###
|
||||
@@ -152,6 +157,10 @@
|
||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
|
||||
##
|
||||
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||
## Defaults to every minute. Set blank to disable this job.
|
||||
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
|
||||
|
||||
########################
|
||||
### General settings ###
|
||||
@@ -271,12 +280,13 @@
|
||||
## The default for new users. If changed, it will be updated during login for existing users.
|
||||
# PASSWORD_ITERATIONS=600000
|
||||
|
||||
## Controls whether users can set password hints. This setting applies globally to all users.
|
||||
## Controls whether users can set or show password hints. This setting applies globally to all users.
|
||||
# PASSWORD_HINTS_ALLOWED=true
|
||||
|
||||
## Controls whether a password hint should be shown directly in the web page if
|
||||
## SMTP service is not configured. Not recommended for publicly-accessible instances
|
||||
## as this provides unauthenticated access to potentially sensitive data.
|
||||
## SMTP service is not configured and password hints are allowed.
|
||||
## Not recommended for publicly-accessible instances because this provides
|
||||
## unauthenticated access to potentially sensitive data.
|
||||
# SHOW_PASSWORD_HINT=false
|
||||
|
||||
#########################
|
||||
@@ -320,15 +330,15 @@
|
||||
## The default is 10 seconds, but this could be to low on slower network connections
|
||||
# ICON_DOWNLOAD_TIMEOUT=10
|
||||
|
||||
## Icon blacklist Regex
|
||||
## Any domains or IPs that match this regex won't be fetched by the icon service.
|
||||
## Block HTTP domains/IPs by Regex
|
||||
## Any domains or IPs that match this regex won't be fetched by the internal HTTP client.
|
||||
## Useful to hide other servers in the local network. Check the WIKI for more details
|
||||
## NOTE: Always enclose this regex withing single quotes!
|
||||
# ICON_BLACKLIST_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$'
|
||||
# HTTP_REQUEST_BLOCK_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$'
|
||||
|
||||
## Any IP which is not defined as a global IP will be blacklisted.
|
||||
## Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address.
|
||||
## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block
|
||||
# ICON_BLACKLIST_NON_GLOBAL_IPS=true
|
||||
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
||||
|
||||
## Client Settings
|
||||
## Enable experimental feature flags for clients.
|
||||
@@ -338,7 +348,10 @@
|
||||
## - "autofill-overlay": Add an overlay menu to form fields for quick access to credentials.
|
||||
## - "autofill-v2": Use the new autofill implementation.
|
||||
## - "browser-fileless-import": Directly import credentials from other providers without a file.
|
||||
## - "extension-refresh": Temporarily enable the new extension design until general availability (should be used with the beta Chrome extension)
|
||||
## - "fido2-vault-credentials": Enable the use of FIDO2 security keys as second factor.
|
||||
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
|
||||
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
|
||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
|
||||
|
||||
## Require new device emails. When a user logs in an email is required to be sent.
|
||||
@@ -362,8 +375,9 @@
|
||||
## Log level
|
||||
## Change the verbosity of the log output
|
||||
## Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
||||
## Setting it to "trace" or "debug" would also show logs for mounted
|
||||
## routes and static file, websocket and alive requests
|
||||
## Setting it to "trace" or "debug" would also show logs for mounted routes and static file, websocket and alive requests
|
||||
## For a specific module append a comma separated `path::to::module=log_level`
|
||||
## For example, to only see debug logs for icons use: LOG_LEVEL="info,vaultwarden::api::icons=debug"
|
||||
# LOG_LEVEL=info
|
||||
|
||||
## Token for the admin interface, preferably an Argon2 PCH string
|
||||
@@ -396,6 +410,14 @@
|
||||
## Multiple values must be separated with a whitespace.
|
||||
# ALLOWED_IFRAME_ANCESTORS=
|
||||
|
||||
## Allowed connect-src (Know the risks!)
|
||||
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
||||
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
||||
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
||||
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
||||
# ALLOWED_CONNECT_SRC=""
|
||||
|
||||
## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in.
|
||||
# LOGIN_RATELIMIT_SECONDS=60
|
||||
## Allow a burst of requests of up to this size, while maintaining the average indicated by `LOGIN_RATELIMIT_SECONDS`.
|
||||
@@ -409,6 +431,18 @@
|
||||
## KNOW WHAT YOU ARE DOING!
|
||||
# ORG_GROUPS_ENABLED=false
|
||||
|
||||
## Increase secure note size limit (Know the risks!)
|
||||
## Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||
## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||
## KNOW WHAT YOU ARE DOING!
|
||||
# INCREASE_NOTE_SIZE_LIMIT=false
|
||||
|
||||
## Enforce Single Org with Reset Password Policy
|
||||
## Enforce that the Single Org policy is enabled before setting the Reset Password policy
|
||||
## Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
|
||||
## 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
|
||||
|
||||
########################
|
||||
### MFA/2FA settings ###
|
||||
########################
|
||||
@@ -422,15 +456,21 @@
|
||||
# YUBICO_SERVER=http://yourdomain.com/wsapi/2.0/verify
|
||||
|
||||
## Duo Settings
|
||||
## You need to configure all options to enable global Duo support, otherwise users would need to configure it themselves
|
||||
## You need to configure the DUO_IKEY, DUO_SKEY, and DUO_HOST options to enable global Duo support.
|
||||
## Otherwise users will need to configure it themselves.
|
||||
## Create an account and protect an application as mentioned in this link (only the first step, not the rest):
|
||||
## https://help.bitwarden.com/article/setup-two-step-login-duo/#create-a-duo-security-account
|
||||
## Then set the following options, based on the values obtained from the last step:
|
||||
# DUO_IKEY=<Integration Key>
|
||||
# DUO_SKEY=<Secret Key>
|
||||
# DUO_IKEY=<Client ID>
|
||||
# DUO_SKEY=<Client Secret>
|
||||
# DUO_HOST=<API Hostname>
|
||||
## After that, you should be able to follow the rest of the guide linked above,
|
||||
## ignoring the fields that ask for the values that you already configured beforehand.
|
||||
##
|
||||
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
|
||||
## Duo no longer supports this, but it still works for some integrations.
|
||||
## If you aren't sure, leave this alone.
|
||||
# DUO_USE_IFRAME=false
|
||||
|
||||
## Email 2FA settings
|
||||
## Email token size
|
||||
|
66
.github/ISSUE_TEMPLATE/bug_report.md
vendored
66
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Use this ONLY for bugs in vaultwarden itself. Use the Discourse forum (link below) to request features or get help with usage/configuration. If in doubt, use the forum.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
<!--
|
||||
# ###
|
||||
NOTE: Please update to the latest version of vaultwarden before reporting an issue!
|
||||
This saves you and us a lot of time and troubleshooting.
|
||||
See:
|
||||
* https://github.com/dani-garcia/vaultwarden/issues/1180
|
||||
* https://github.com/dani-garcia/vaultwarden/wiki/Updating-the-vaultwarden-image
|
||||
# ###
|
||||
-->
|
||||
|
||||
<!--
|
||||
Please fill out the following template to make solving your problem easier and faster for us.
|
||||
This is only a guideline. If you think that parts are unnecessary for your issue, feel free to remove them.
|
||||
|
||||
Remember to hide/redact personal or confidential information,
|
||||
such as passwords, IP addresses, and DNS names as appropriate.
|
||||
-->
|
||||
|
||||
### Subject of the issue
|
||||
<!-- Describe your issue here. -->
|
||||
|
||||
### Deployment environment
|
||||
|
||||
<!--
|
||||
=========================================================================================
|
||||
Preferably, use the `Generate Support String` button on the admin page's Diagnostics tab.
|
||||
That will auto-generate most of the info requested in this section.
|
||||
=========================================================================================
|
||||
-->
|
||||
|
||||
<!-- The version number, obtained from the logs (at startup) or the admin diagnostics page -->
|
||||
<!-- This is NOT the version number shown on the web vault, which is versioned separately from vaultwarden -->
|
||||
<!-- Remember to check if your issue exists on the latest version first! -->
|
||||
* vaultwarden version:
|
||||
|
||||
<!-- How the server was installed: Docker image, OS package, built from source, etc. -->
|
||||
* Install method:
|
||||
|
||||
* Clients used: <!-- web vault, desktop, Android, iOS, etc. (if applicable) -->
|
||||
|
||||
* Reverse proxy and version: <!-- if applicable -->
|
||||
|
||||
* MySQL/MariaDB or PostgreSQL version: <!-- if applicable -->
|
||||
|
||||
* Other relevant details:
|
||||
|
||||
### Steps to reproduce
|
||||
<!-- Tell us how to reproduce this issue. What parameters did you set (differently from the defaults)
|
||||
and how did you start vaultwarden? -->
|
||||
|
||||
### Expected behaviour
|
||||
<!-- Tell us what you expected to happen -->
|
||||
|
||||
### Actual behaviour
|
||||
<!-- Tell us what actually happened -->
|
||||
|
||||
### Troubleshooting data
|
||||
<!-- Share any log files, screenshots, or other relevant troubleshooting data -->
|
167
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
167
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
name: Bug Report
|
||||
description: File a bug report
|
||||
labels: ["bug"]
|
||||
body:
|
||||
#
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
Please *do not* submit feature requests or ask for help on how to configure Vaultwarden here.
|
||||
|
||||
The [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions/) has sections for Questions and Ideas.
|
||||
|
||||
Also, make sure you are running [](https://github.com/dani-garcia/vaultwarden/releases/latest) of Vaultwarden!
|
||||
And search for existing open or closed issues or discussions regarding your topic before posting.
|
||||
|
||||
Be sure to check and validate the Vaultwarden Admin Diagnostics (`/admin/diagnostics`) page for any errors!
|
||||
See here [how to enable the admin page](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page).
|
||||
#
|
||||
- id: support-string
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Vaultwarden Support String
|
||||
description: Output of the **Generate Support String** from the `/admin/diagnostics` page.
|
||||
placeholder: |
|
||||
1. Go to the Vaultwarden Admin of your instance https://example.domain.tld/admin/diagnostics
|
||||
2. Click on `Generate Support String`
|
||||
3. Click on `Copy To Clipboard`
|
||||
4. Replace this text by pasting it into this textarea without any modifications
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: version
|
||||
type: input
|
||||
attributes:
|
||||
label: Vaultwarden Build Version
|
||||
description: What version of Vaultwarden are you running?
|
||||
placeholder: ex. v1.31.0 or v1.32.0-3466a804
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: deployment
|
||||
type: dropdown
|
||||
attributes:
|
||||
label: Deployment method
|
||||
description: How did you deploy Vaultwarden?
|
||||
multiple: false
|
||||
options:
|
||||
- Official Container Image
|
||||
- Build from source
|
||||
- OS Package (apt, yum/dnf, pacman, apk, nix, ...)
|
||||
- Manually Extracted from Container Image
|
||||
- Downloaded from GitHub Actions Release Workflow
|
||||
- Other method
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: deployment-other
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Custom deployment method
|
||||
description: If you deployed Vaultwarden via any other method, please describe how.
|
||||
#
|
||||
- id: reverse-proxy
|
||||
type: input
|
||||
attributes:
|
||||
label: Reverse Proxy
|
||||
description: Are you using a reverse proxy, if so which and what version?
|
||||
placeholder: ex. nginx 1.26.2, caddy 2.8.4, traefik 3.1.2, haproxy 3.0
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: os
|
||||
type: dropdown
|
||||
attributes:
|
||||
label: Host/Server Operating System
|
||||
description: On what operating system are you running the Vaultwarden server?
|
||||
multiple: false
|
||||
options:
|
||||
- Linux
|
||||
- NAS/SAN
|
||||
- Cloud
|
||||
- Windows
|
||||
- macOS
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: os-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Operating System Version
|
||||
description: What version of the operating system(s) are you seeing the problem on?
|
||||
placeholder: ex. Arch Linux, Ubuntu 24.04, Kubernetes, Synology DSM 7.x, Windows 11
|
||||
#
|
||||
- id: clients
|
||||
type: dropdown
|
||||
attributes:
|
||||
label: Clients
|
||||
description: What client(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Web Vault
|
||||
- Browser Extension
|
||||
- CLI
|
||||
- Desktop
|
||||
- Android
|
||||
- iOS
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: client-version
|
||||
type: input
|
||||
attributes:
|
||||
label: Client Version
|
||||
description: What version(s) of the client(s) are you seeing the problem on?
|
||||
placeholder: ex. CLI v2024.7.2, Firefox 130 - v2024.7.0
|
||||
#
|
||||
- id: reproduce
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: How can we reproduce the behavior.
|
||||
value: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. Click on '...'
|
||||
5. Etc '...'
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: expected
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Expected Result
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: actual
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Actual Result
|
||||
description: A clear and concise description of what is happening.
|
||||
validations:
|
||||
required: true
|
||||
#
|
||||
- id: logs
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Provide the logs generated by Vaultwarden during the time this issue occurs.
|
||||
render: text
|
||||
#
|
||||
- id: screenshots
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: If applicable, add screenshots and/or a short video to help explain your problem.
|
||||
#
|
||||
- id: additional-context
|
||||
type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discourse forum for vaultwarden
|
||||
url: https://vaultwarden.discourse.group/
|
||||
about: Use this forum to request features or get help with usage/configuration.
|
||||
- name: GitHub Discussions for vaultwarden
|
||||
- name: GitHub Discussions for Vaultwarden
|
||||
url: https://github.com/dani-garcia/vaultwarden/discussions
|
||||
about: An alternative to the Discourse forum, if this is easier for you.
|
||||
about: Use the discussions to request features or get help with usage/configuration.
|
||||
- name: Discourse forum for Vaultwarden
|
||||
url: https://vaultwarden.discourse.group/
|
||||
about: An alternative to the GitHub Discussions, if this is easier for you.
|
||||
|
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -28,6 +28,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 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
|
||||
# Make warnings errors, this is to prevent warnings slipping through.
|
||||
@@ -46,7 +47,7 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
# End Checkout the repo
|
||||
|
||||
|
||||
@@ -74,7 +75,7 @@ jobs:
|
||||
|
||||
# Only install the clippy and rustfmt components on the default rust-toolchain
|
||||
- name: "Install rust-toolchain version"
|
||||
uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master @ Jun 13, 2024, 6:20 PM GMT+2
|
||||
uses: dtolnay/rust-toolchain@315e265cd78dad1e1dcf3a5074f6d6c47029d5aa # master @ Nov 18, 2024, 5:36 AM GMT+1
|
||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
@@ -84,7 +85,7 @@ jobs:
|
||||
|
||||
# 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@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master @ Jun 13, 2024, 6:20 PM GMT+2
|
||||
uses: dtolnay/rust-toolchain@315e265cd78dad1e1dcf3a5074f6d6c47029d5aa # master @ Nov 18, 2024, 5:36 AM GMT+1
|
||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
@@ -106,7 +107,7 @@ jobs:
|
||||
# End Show environment
|
||||
|
||||
# Enable Rust Caching
|
||||
- uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5
|
||||
with:
|
||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||
@@ -116,6 +117,12 @@ jobs:
|
||||
|
||||
# Run cargo tests
|
||||
# First test all features together, afterwards test them separately.
|
||||
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,query_logger"
|
||||
id: test_sqlite_mysql_postgresql_mimalloc_logger
|
||||
if: $${{ always() }}
|
||||
run: |
|
||||
cargo test --features sqlite,mysql,postgresql,enable_mimalloc,query_logger
|
||||
|
||||
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
|
||||
id: test_sqlite_mysql_postgresql_mimalloc
|
||||
if: $${{ always() }}
|
||||
@@ -175,6 +182,7 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|Job|Status|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---|------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,query_logger)|${{ steps.test_sqlite_mysql_postgresql_mimalloc_logger.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|test (sqlite,mysql,postgresql)|${{ steps.test_sqlite_mysql_postgresql.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|test (sqlite)|${{ steps.test_sqlite.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
|
30
.github/workflows/hadolint.yml
vendored
30
.github/workflows/hadolint.yml
vendored
@@ -8,14 +8,26 @@ on: [
|
||||
jobs:
|
||||
hadolint:
|
||||
name: Validate Dockerfile syntax
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
# End Checkout the repo
|
||||
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 2
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
||||
- name: Download hadolint
|
||||
shell: bash
|
||||
@@ -26,8 +38,18 @@ jobs:
|
||||
HADOLINT_VERSION: 2.12.0
|
||||
# End Download hadolint
|
||||
|
||||
# Test Dockerfiles
|
||||
# Test Dockerfiles with hadolint
|
||||
- name: Run hadolint
|
||||
shell: bash
|
||||
run: hadolint docker/Dockerfile.{debian,alpine}
|
||||
# End Test Dockerfiles
|
||||
# End Test Dockerfiles with hadolint
|
||||
|
||||
# Test Dockerfiles with docker build checks
|
||||
- name: Run docker build check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Checking docker/Dockerfile.debian"
|
||||
docker build --check . -f docker/Dockerfile.debian
|
||||
echo "Checking docker/Dockerfile.alpine"
|
||||
docker build --check . -f docker/Dockerfile.alpine
|
||||
# End Test Dockerfiles with docker build checks
|
||||
|
30
.github/workflows/release.yml
vendored
30
.github/workflows/release.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
# 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:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
if: ${{ github.ref_type == 'branch' }}
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: skip_check
|
||||
if: ${{ needs.skip_check.outputs.should_skip != 'true' && github.repository == 'dani-garcia/vaultwarden' }}
|
||||
@@ -58,24 +58,24 @@ jobs:
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize QEMU binfmt support
|
||||
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
|
||||
uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0
|
||||
with:
|
||||
platforms: "arm64,arm"
|
||||
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@d70bba72b1f3fd22344832f00baa16ece964efeb # v3.3.0
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 3, 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:
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 3
|
||||
max-parallelism = 2
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}localhost:5000/vaultwarden/server" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
- name: Bake ${{ matrix.base_image }} containers
|
||||
uses: docker/bake-action@1c5f18a523c4c68524cfbc5161494d8bb5b29d20 # v5.0.1
|
||||
uses: docker/bake-action@2e3d19baedb14545e5d41222653874f25d5b4dfb # v5.10.0
|
||||
env:
|
||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -223,28 +223,28 @@ jobs:
|
||||
|
||||
# Upload artifacts to Github Actions
|
||||
- name: "Upload amd64 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64
|
||||
path: vaultwarden-amd64
|
||||
|
||||
- name: "Upload arm64 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64
|
||||
path: vaultwarden-arm64
|
||||
|
||||
- name: "Upload armv7 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7
|
||||
path: vaultwarden-armv7
|
||||
|
||||
- name: "Upload armv6 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6
|
||||
|
2
.github/workflows/releasecache-cleanup.yml
vendored
2
.github/workflows/releasecache-cleanup.yml
vendored
@@ -13,7 +13,7 @@ name: Cleanup
|
||||
jobs:
|
||||
releasecache-cleanup:
|
||||
name: Releasecache Cleanup
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: true
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
16
.github/workflows/trivy.yml
vendored
16
.github/workflows/trivy.yml
vendored
@@ -9,15 +9,18 @@ on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '00 12 * * *'
|
||||
- cron: '08 11 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
# Only run this in the master repo and not on forks
|
||||
# When all forks run this at the same time, it is causing `Too Many Requests` issues
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
name: Check
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,10 +28,13 @@ jobs:
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@7c2007bcb556501da015201bcba5aa14069b74e2 # v0.23.0
|
||||
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
|
||||
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
|
||||
with:
|
||||
scan-type: repo
|
||||
ignore-unfixed: true
|
||||
@@ -37,6 +43,6 @@ jobs:
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@2bbafcdd7fbf96243689e764c2f15d9735164f33 # v3.25.10
|
||||
uses: github/codeql-action/upload-sarif@86b04fb0e47484f7282357688f21d5d0e32175fe # v3.27.5
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
@@ -1,7 +1,7 @@
|
||||
---
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
|
1690
Cargo.lock
generated
1690
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
109
Cargo.toml
109
Cargo.toml
@@ -3,7 +3,7 @@ name = "vaultwarden"
|
||||
version = "1.0.0"
|
||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.78.0"
|
||||
rust-version = "1.82.0"
|
||||
resolver = "2"
|
||||
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
@@ -18,42 +18,42 @@ build = "build.rs"
|
||||
enable_syslog = []
|
||||
mysql = ["diesel/mysql", "diesel_migrations/mysql"]
|
||||
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
|
||||
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "libsqlite3-sys"]
|
||||
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"]
|
||||
# Enable to use a vendored and statically linked openssl
|
||||
vendored_openssl = ["openssl/vendored"]
|
||||
# Enable MiMalloc memory allocator to replace the default malloc
|
||||
# This can improve performance for Alpine builds
|
||||
enable_mimalloc = ["mimalloc"]
|
||||
enable_mimalloc = ["dep:mimalloc"]
|
||||
# This is a development dependency, and should only be used during development!
|
||||
# It enables the usage of the diesel_logger crate, which is able to output the generated queries.
|
||||
# You also need to set an env variable `QUERY_LOGGER=1` to fully activate this so you do not have to re-compile
|
||||
# if you want to turn off the logging for a specific run.
|
||||
query_logger = ["diesel_logger"]
|
||||
query_logger = ["dep:diesel_logger"]
|
||||
|
||||
# Enable unstable features, requires nightly
|
||||
# Currently only used to enable rusts official ip support
|
||||
unstable = []
|
||||
|
||||
[target."cfg(not(windows))".dependencies]
|
||||
[target."cfg(unix)".dependencies]
|
||||
# Logging
|
||||
syslog = "6.1.1"
|
||||
syslog = "7.0.0"
|
||||
|
||||
[dependencies]
|
||||
# Logging
|
||||
log = "0.4.22"
|
||||
fern = { version = "0.6.2", features = ["syslog-6", "reopen-1"] }
|
||||
tracing = { version = "0.1.40", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
|
||||
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
|
||||
|
||||
# A `dotenv` implementation for Rust
|
||||
dotenvy = { version = "0.15.7", default-features = false }
|
||||
|
||||
# Lazy initialization
|
||||
once_cell = "1.19.0"
|
||||
once_cell = "1.20.2"
|
||||
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.19"
|
||||
num-derive = "0.4.2"
|
||||
bigdecimal = "0.4.5"
|
||||
bigdecimal = "0.4.7"
|
||||
|
||||
# Web framework
|
||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
||||
@@ -63,35 +63,35 @@ rocket_ws = { version ="0.1.1" }
|
||||
rmpv = "1.3.0" # MessagePack library
|
||||
|
||||
# Concurrent HashMap used for WebSocket messaging and favicons
|
||||
dashmap = "6.0.1"
|
||||
dashmap = "6.1.0"
|
||||
|
||||
# Async futures
|
||||
futures = "0.3.30"
|
||||
tokio = { version = "1.38.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
futures = "0.3.31"
|
||||
tokio = { version = "1.42.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = { version = "1.0.204", features = ["derive"] }
|
||||
serde_json = "1.0.120"
|
||||
serde = { version = "1.0.216", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "2.2.1", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel = { version = "2.2.6", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel_migrations = "2.2.0"
|
||||
diesel_logger = { version = "0.3.0", optional = true }
|
||||
diesel_logger = { version = "0.4.0", optional = true }
|
||||
|
||||
# Bundled/Static SQLite
|
||||
libsqlite3-sys = { version = "0.28.0", features = ["bundled"], optional = true }
|
||||
libsqlite3-sys = { version = "0.30.1", features = ["bundled"], optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = { version = "0.8.5", features = ["small_rng"] }
|
||||
ring = "0.17.8"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.9.1", features = ["v4"] }
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
|
||||
# Date and time libraries
|
||||
chrono = { version = "0.4.38", features = ["clock", "serde"], default-features = false }
|
||||
chrono-tz = "0.9.0"
|
||||
time = "0.3.36"
|
||||
chrono = { version = "0.4.39", features = ["clock", "serde"], default-features = false }
|
||||
chrono-tz = "0.10.0"
|
||||
time = "0.3.37"
|
||||
|
||||
# Job scheduler
|
||||
job_scheduler_ng = "2.0.5"
|
||||
@@ -106,56 +106,56 @@ jsonwebtoken = "9.3.0"
|
||||
totp-lite = "2.0.1"
|
||||
|
||||
# Yubico Library
|
||||
yubico = { version = "0.11.0", features = ["online-tokio"], default-features = false }
|
||||
yubico = { version = "0.12.0", features = ["online-tokio"], default-features = false }
|
||||
|
||||
# WebAuthn libraries
|
||||
webauthn-rs = "0.3.2"
|
||||
|
||||
# Handling of URL's for WebAuthn and favicons
|
||||
url = "2.5.2"
|
||||
url = "2.5.4"
|
||||
|
||||
# Email libraries
|
||||
lettre = { version = "0.11.7", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false }
|
||||
lettre = { version = "0.11.11", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false }
|
||||
percent-encoding = "2.3.1" # URL encoding library used for URL's in the emails
|
||||
email_address = "0.2.5"
|
||||
email_address = "0.2.9"
|
||||
|
||||
# HTML Template library
|
||||
handlebars = { version = "5.1.2", features = ["dir_source"] }
|
||||
handlebars = { version = "6.2.0", features = ["dir_source"] }
|
||||
|
||||
# HTTP client (Used for favicons, version check, DUO and HIBP API)
|
||||
reqwest = { version = "0.12.5", features = ["native-tls-alpn", "stream", "json", "gzip", "brotli", "socks", "cookies"] }
|
||||
hickory-resolver = "0.24.1"
|
||||
reqwest = { version = "0.12.9", features = ["native-tls-alpn", "stream", "json", "gzip", "brotli", "socks", "cookies"] }
|
||||
hickory-resolver = "0.24.2"
|
||||
|
||||
# Favicon extraction libraries
|
||||
html5gum = "0.5.7"
|
||||
regex = { version = "1.10.5", features = ["std", "perf", "unicode-perl"], default-features = false }
|
||||
html5gum = "0.7.0"
|
||||
regex = { version = "1.11.1", features = ["std", "perf", "unicode-perl"], default-features = false }
|
||||
data-url = "0.3.1"
|
||||
bytes = "1.6.0"
|
||||
bytes = "1.9.0"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "0.52.0", features = ["async"] }
|
||||
cached = { version = "0.54.0", features = ["async"] }
|
||||
|
||||
# Used for custom short lived cookie jar during favicon extraction
|
||||
cookie = "0.18.1"
|
||||
cookie_store = "0.21.0"
|
||||
cookie_store = "0.21.1"
|
||||
|
||||
# Used by U2F, JWT and PostgreSQL
|
||||
openssl = "0.10.64"
|
||||
openssl = "0.10.68"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
|
||||
# Macro ident concatenation
|
||||
paste = "1.0.15"
|
||||
governor = "0.6.3"
|
||||
governor = "0.8.0"
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.23"
|
||||
semver = "1.0.24"
|
||||
|
||||
# Allow overriding the default memory allocator
|
||||
# Mainly used for the musl builds, since the default musl malloc is very slow
|
||||
mimalloc = { version = "0.1.43", features = ["secure"], default-features = false, optional = true }
|
||||
which = "6.0.1"
|
||||
which = "7.0.0"
|
||||
|
||||
# Argon2 library with support for the PHC format
|
||||
argon2 = "0.5.3"
|
||||
@@ -163,6 +163,13 @@ argon2 = "0.5.3"
|
||||
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
|
||||
rpassword = "7.3.1"
|
||||
|
||||
# Loading a dynamic CSS Stylesheet
|
||||
grass_compiler = { version = "0.13.4", default-features = false }
|
||||
|
||||
[patch.crates-io]
|
||||
# Patch yubico to remove duplicate crates of older versions
|
||||
yubico = { git = "https://github.com/BlackDex/yubico-rs", rev = "00df14811f58155c0f02e3ab10f1570ed3e115c6" }
|
||||
|
||||
# Strip debuginfo from the release builds
|
||||
# The symbols are the provide better panic traces
|
||||
# Also enable fat LTO and use 1 codegen unit for optimizations
|
||||
@@ -198,33 +205,44 @@ lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
# Linting config
|
||||
# https://doc.rust-lang.org/rustc/lints/groups.html
|
||||
[lints.rust]
|
||||
# Forbid
|
||||
unsafe_code = "forbid"
|
||||
non_ascii_idents = "forbid"
|
||||
|
||||
# Deny
|
||||
deprecated_in_future = "deny"
|
||||
future_incompatible = { level = "deny", priority = -1 }
|
||||
keyword_idents = { level = "deny", priority = -1 }
|
||||
let_underscore = { level = "deny", priority = -1 }
|
||||
noop_method_call = "deny"
|
||||
refining_impl_trait = { level = "deny", priority = -1 }
|
||||
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||
edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again
|
||||
single_use_lifetimes = "deny"
|
||||
trivial_casts = "deny"
|
||||
trivial_numeric_casts = "deny"
|
||||
unused = { level = "deny", priority = -1 }
|
||||
unused_import_braces = "deny"
|
||||
unused_lifetimes = "deny"
|
||||
deprecated_in_future = "deny"
|
||||
unused_qualifications = "deny"
|
||||
variant_size_differences = "deny"
|
||||
|
||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||
[lints.clippy]
|
||||
# Allow
|
||||
# We need this since Rust v1.76+, since it has some bugs
|
||||
# https://github.com/rust-lang/rust-clippy/issues/12016
|
||||
blocks_in_conditions = "allow"
|
||||
# Warn
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Deny
|
||||
case_sensitive_file_extension_comparisons = "deny"
|
||||
cast_lossless = "deny"
|
||||
clone_on_ref_ptr = "deny"
|
||||
equatable_if_let = "deny"
|
||||
filter_map_next = "deny"
|
||||
float_cmp_const = "deny"
|
||||
inefficient_to_string = "deny"
|
||||
iter_on_empty_collections = "deny"
|
||||
@@ -234,13 +252,18 @@ macro_use_imports = "deny"
|
||||
manual_assert = "deny"
|
||||
manual_instant_elapsed = "deny"
|
||||
manual_string_new = "deny"
|
||||
match_on_vec_items = "deny"
|
||||
match_wildcard_for_single_variants = "deny"
|
||||
mem_forget = "deny"
|
||||
needless_continue = "deny"
|
||||
needless_lifetimes = "deny"
|
||||
option_option = "deny"
|
||||
string_add_assign = "deny"
|
||||
string_to_string = "deny"
|
||||
unnecessary_join = "deny"
|
||||
unnecessary_self_imports = "deny"
|
||||
unnested_or_patterns = "deny"
|
||||
unused_async = "deny"
|
||||
unused_self = "deny"
|
||||
verbose_file_reads = "deny"
|
||||
zero_sized_map_values = "deny"
|
||||
|
202
README.md
202
README.md
@@ -1,102 +1,144 @@
|
||||
### Alternative implementation of the Bitwarden server API written in Rust and compatible with [upstream Bitwarden clients](https://bitwarden.com/download/)*, perfect for self-hosted deployment where running the official resource-heavy service might not be ideal.
|
||||

|
||||
|
||||
📢 Note: This project was known as Bitwarden_RS and has been renamed to separate itself from the official Bitwarden server in the hopes of avoiding confusion and trademark/branding issues. Please see [#1642](https://github.com/dani-garcia/vaultwarden/discussions/1642) for more explanation.
|
||||
An alternative server implementation of the Bitwarden Client API, written in Rust and compatible with [official Bitwarden clients](https://bitwarden.com/download/) [[disclaimer](#disclaimer)], perfect for self-hosted deployment where running the official resource-heavy service might not be ideal.
|
||||
|
||||
---
|
||||
[](https://github.com/dani-garcia/vaultwarden/actions/workflows/build.yml)
|
||||
[](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden)
|
||||
[](https://hub.docker.com/r/vaultwarden/server)
|
||||
[](https://quay.io/repository/vaultwarden/server)
|
||||
[](https://deps.rs/repo/github/dani-garcia/vaultwarden)
|
||||
[](https://github.com/dani-garcia/vaultwarden/releases/latest)
|
||||
[](https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt)
|
||||
[](https://matrix.to/#/#vaultwarden:matrix.org)
|
||||
|
||||
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/vaultwarden).
|
||||
[](https://github.com/dani-garcia/vaultwarden/releases/latest)
|
||||
[](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden)
|
||||
[](https://hub.docker.com/r/vaultwarden/server)
|
||||
[](https://quay.io/repository/vaultwarden/server) <br>
|
||||
[](https://github.com/dani-garcia/vaultwarden/graphs/contributors)
|
||||
[](https://github.com/dani-garcia/vaultwarden/network/members)
|
||||
[](https://github.com/dani-garcia/vaultwarden/stargazers)
|
||||
[](https://github.com/dani-garcia/vaultwarden/issues)
|
||||
[](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue+is%3Aclosed)
|
||||
[](https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt) <br>
|
||||
[%3D'svg'%5D%2F*%5Blocal-name()%3D'g'%5D%5B2%5D%2F*%5Blocal-name()%3D'text'%5D%5B4%5D&style=flat-square&logo=rust&label=dependencies&color=005AA4)](https://deps.rs/repo/github/dani-garcia/vaultwarden)
|
||||
[](https://github.com/dani-garcia/vaultwarden/actions/workflows/release.yml)
|
||||
[](https://github.com/dani-garcia/vaultwarden/actions/workflows/build.yml) <br>
|
||||
[](https://matrix.to/#/#vaultwarden:matrix.org)
|
||||
[](https://github.com/dani-garcia/vaultwarden/discussions)
|
||||
[](https://vaultwarden.discourse.group/)
|
||||
|
||||
**This project is not associated with the [Bitwarden](https://bitwarden.com/) project nor Bitwarden, Inc.**
|
||||
> [!IMPORTANT]
|
||||
> **When using this server, please report any bugs or suggestions directly to us (see [Get in touch](#get-in-touch)), regardless of whatever clients you are using (mobile, desktop, browser...). DO NOT use the official Bitwarden support channels.**
|
||||
|
||||
#### ⚠️**IMPORTANT**⚠️: When using this server, please report any bugs or suggestions to us directly (look at the bottom of this page for ways to get in touch), regardless of whatever clients you are using (mobile, desktop, browser...). DO NOT use the official support channels.
|
||||
|
||||
---
|
||||
<br>
|
||||
|
||||
## Features
|
||||
|
||||
Basically full implementation of Bitwarden API is provided including:
|
||||
A nearly complete implementation of the Bitwarden Client API is provided, including:
|
||||
|
||||
* Organizations support
|
||||
* Attachments and Send
|
||||
* Vault API support
|
||||
* Serving the static files for Vault interface
|
||||
* Website icons API
|
||||
* Authenticator and U2F support
|
||||
* YubiKey and Duo support
|
||||
* Emergency Access
|
||||
* [Personal Vault](https://bitwarden.com/help/managing-items/)
|
||||
* [Send](https://bitwarden.com/help/about-send/)
|
||||
* [Attachments](https://bitwarden.com/help/attachments/)
|
||||
* [Website icons](https://bitwarden.com/help/website-icons/)
|
||||
* [Personal API Key](https://bitwarden.com/help/personal-api-key/)
|
||||
* [Organizations](https://bitwarden.com/help/getting-started-organizations/)
|
||||
- [Collections](https://bitwarden.com/help/about-collections/),
|
||||
[Password Sharing](https://bitwarden.com/help/sharing/),
|
||||
[Member Roles](https://bitwarden.com/help/user-types-access-control/),
|
||||
[Groups](https://bitwarden.com/help/about-groups/),
|
||||
[Event Logs](https://bitwarden.com/help/event-logs/),
|
||||
[Admin Password Reset](https://bitwarden.com/help/admin-reset/),
|
||||
[Directory Connector](https://bitwarden.com/help/directory-sync/),
|
||||
[Policies](https://bitwarden.com/help/policies/)
|
||||
* [Multi/Two Factor Authentication](https://bitwarden.com/help/bitwarden-field-guide-two-step-login/)
|
||||
- [Authenticator](https://bitwarden.com/help/setup-two-step-login-authenticator/),
|
||||
[Email](https://bitwarden.com/help/setup-two-step-login-email/),
|
||||
[FIDO2 WebAuthn](https://bitwarden.com/help/setup-two-step-login-fido/),
|
||||
[YubiKey](https://bitwarden.com/help/setup-two-step-login-yubikey/),
|
||||
[Duo](https://bitwarden.com/help/setup-two-step-login-duo/)
|
||||
* [Emergency Access](https://bitwarden.com/help/emergency-access/)
|
||||
* [Vaultwarden Admin Backend](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page)
|
||||
* [Modified Web Vault client](https://github.com/dani-garcia/bw_web_builds) (Bundled within our containers)
|
||||
|
||||
## Installation
|
||||
Pull the docker image and mount a volume from the host for persistent storage:
|
||||
|
||||
```sh
|
||||
docker pull vaultwarden/server:latest
|
||||
docker run -d --name vaultwarden -v /vw-data/:/data/ --restart unless-stopped -p 80:80 vaultwarden/server:latest
|
||||
```
|
||||
This will preserve any persistent data under /vw-data/, you can adapt the path to whatever suits you.
|
||||
|
||||
**IMPORTANT**: Most modern web browsers disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault via HTTPS or localhost.
|
||||
|
||||
This can be configured in [vaultwarden directly](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)).
|
||||
|
||||
If you have an available domain name, you can get HTTPS certificates with [Let's Encrypt](https://letsencrypt.org/), or you can generate self-signed certificates with utilities like [mkcert](https://github.com/FiloSottile/mkcert). Some proxies automatically do this step, like Caddy (see examples linked above).
|
||||
<br>
|
||||
|
||||
## Usage
|
||||
See the [vaultwarden wiki](https://github.com/dani-garcia/vaultwarden/wiki) for more information on how to configure and run the vaultwarden server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Most modern web browsers disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault via HTTPS or localhost.
|
||||
>
|
||||
>This can be configured in [Vaultwarden directly](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)).
|
||||
>
|
||||
>If you have an available domain name, you can get HTTPS certificates with [Let's Encrypt](https://letsencrypt.org/), or you can generate self-signed certificates with utilities like [mkcert](https://github.com/FiloSottile/mkcert). Some proxies automatically do this step, like Caddy or Traefik (see examples linked above).
|
||||
|
||||
> [!TIP]
|
||||
>**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).**
|
||||
|
||||
The main way to 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).
|
||||
|
||||
There are also [community driven packages](https://github.com/dani-garcia/vaultwarden/wiki/Third-party-packages) which can be used, but those might be lagging behind the latest version or might deviate in the way Vaultwarden is configured, as described in our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).
|
||||
|
||||
### Docker/Podman CLI
|
||||
|
||||
Pull the container image and mount a volume from the host for persistent storage.<br>
|
||||
You can replace `docker` with `podman` if you prefer to use podman.
|
||||
|
||||
```shell
|
||||
docker pull vaultwarden/server:latest
|
||||
docker run --detach --name vaultwarden \
|
||||
--env DOMAIN="https://vw.domain.tld" \
|
||||
--volume /vw-data/:/data/ \
|
||||
--restart unless-stopped \
|
||||
--publish 80:80 \
|
||||
vaultwarden/server:latest
|
||||
```
|
||||
|
||||
This will preserve any persistent data under `/vw-data/`, you can adapt the path to whatever suits you.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
To use Docker compose you need to create a `compose.yaml` which will hold the configuration to run the Vaultwarden container.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:latest
|
||||
container_name: vaultwarden
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DOMAIN: "https://vw.domain.tld"
|
||||
volumes:
|
||||
- ./vw-data/:/data/
|
||||
ports:
|
||||
- 80:80
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## Get in touch
|
||||
To ask a question, offer suggestions or new features or to get help configuring or installing the software, please use [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions) or [the forum](https://vaultwarden.discourse.group/).
|
||||
|
||||
If you spot any bugs or crashes with vaultwarden itself, please [create an issue](https://github.com/dani-garcia/vaultwarden/issues/). Make sure you are on the latest version and there aren't any similar issues open, though!
|
||||
Have a question, suggestion or need help? Join our community on [Matrix](https://matrix.to/#/#vaultwarden:matrix.org), [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions) or [Discourse Forums](https://vaultwarden.discourse.group/).
|
||||
|
||||
If you prefer to chat, we're usually hanging around at [#vaultwarden:matrix.org](https://matrix.to/#/#vaultwarden:matrix.org) room on Matrix. Feel free to join us!
|
||||
Encountered a bug or crash? Please search our issue tracker and discussions to see if it's already been reported. If not, please [start a new discussion](https://github.com/dani-garcia/vaultwarden/discussions) or [create a new issue](https://github.com/dani-garcia/vaultwarden/issues/). Ensure you're using the latest version of Vaultwarden and there aren't any similar issues open or closed!
|
||||
|
||||
<br>
|
||||
|
||||
## Contributors
|
||||
|
||||
### Sponsors
|
||||
Thanks for your contribution to the project!
|
||||
|
||||
<!--
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/username">
|
||||
<img src="https://avatars.githubusercontent.com/u/725423?s=75&v=4" width="75px;" alt="username"/>
|
||||
<br />
|
||||
<sub><b>username</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
[](https://github.com/dani-garcia/vaultwarden/graphs/contributors)<br>
|
||||
[](https://github.com/dani-garcia/vaultwarden/graphs/contributors)
|
||||
|
||||
<br/>
|
||||
-->
|
||||
<br>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/themightychris" style="width: 75px">
|
||||
<sub><b>Chris Alfano</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/numberly" style="width: 75px">
|
||||
<sub><b>Numberly</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/IQ333777" style="width: 75px">
|
||||
<sub><b>IQ333777</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
## Disclaimer
|
||||
|
||||
**This project is not associated with [Bitwarden](https://bitwarden.com/) or Bitwarden, Inc.**
|
||||
|
||||
However, one of the active maintainers for Vaultwarden is employed by Bitwarden and is allowed to contribute to the project on their own time. These contributions are independent of Bitwarden and are reviewed by other maintainers.
|
||||
|
||||
The maintainers work together to set the direction for the project, focusing on serving the self-hosting community, including individuals, families, and small organizations, while ensuring the project's sustainability.
|
||||
|
||||
**Please note:** We cannot be held liable for any data loss that may occur while using Vaultwarden. This includes passwords, attachments, and other information handled by the application. We highly recommend performing regular backups of your files and database. However, should you experience data loss, we encourage you to contact us immediately.
|
||||
|
||||
<br>
|
||||
|
||||
## Bitwarden_RS
|
||||
|
||||
This project was known as Bitwarden_RS and has been renamed to separate itself from the official Bitwarden server in the hopes of avoiding confusion and trademark/branding issues.<br>
|
||||
Please see [#1642 - v1.21.0 release and project rename to Vaultwarden](https://github.com/dani-garcia/vaultwarden/discussions/1642) for more explanation.
|
||||
|
14
SECURITY.md
14
SECURITY.md
@@ -21,7 +21,7 @@ notify us. We welcome working with you to resolve the issue promptly. Thanks in
|
||||
The following bug classes are out-of scope:
|
||||
|
||||
- Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues)
|
||||
- Bugs that are not part of Vaultwarden, like on the the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated
|
||||
- Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated
|
||||
- Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer
|
||||
- Attacks requiring physical access to a user's device
|
||||
- Issues related to software or protocols not under Vaultwarden's control
|
||||
@@ -39,7 +39,11 @@ Thank you for helping keep Vaultwarden and our users safe!
|
||||
|
||||
# How to contact us
|
||||
|
||||
- You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (user: `@danig:matrix.org`)
|
||||
- You can send an  to report a security issue.
|
||||
- If you want to send an encrypted email you can use the following GPG key:<br>
|
||||
https://keyserver.ubuntu.com/pks/lookup?search=0xB9B7A108373276BF3C0406F9FC8A7D14C3CD543A&fingerprint=on&op=index
|
||||
- You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`)
|
||||
- You can send an  to report a security issue.<br>
|
||||
If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC<br>
|
||||
It can be found on several public GPG key servers.<br>
|
||||
* https://keys.openpgp.org/search?q=security%40vaultwarden.org
|
||||
* https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org
|
||||
* https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
||||
* https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
||||
|
@@ -1,12 +1,13 @@
|
||||
---
|
||||
vault_version: "v2024.5.1b"
|
||||
vault_image_digest: "sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375"
|
||||
# Cross Compile Docker Helper Scripts v1.4.0
|
||||
vault_version: "v2024.6.2c"
|
||||
vault_image_digest: "sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b"
|
||||
# Cross Compile Docker Helper Scripts v1.5.0
|
||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||
xx_image_digest: "sha256:0cd3f05c72d6c9b038eb135f91376ee1169ef3a330d34e418e65e2a5c2e9c0d4"
|
||||
rust_version: 1.79.0 # Rust version to be used
|
||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||
xx_image_digest: "sha256:1978e7a58a1777cb0ef0dde76bad60b7914b21da57cfa88047875e4f364297aa"
|
||||
rust_version: 1.83.0 # Rust version to be used
|
||||
debian_version: bookworm # Debian release name to be used
|
||||
alpine_version: "3.20" # Alpine version to be used
|
||||
alpine_version: "3.21" # Alpine version to be used
|
||||
# For which platforms/architectures will we try to build images
|
||||
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
|
||||
# Determine the build images per OS/Arch
|
||||
|
@@ -1,4 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform
|
||||
|
||||
# This file was generated using a Jinja2 template.
|
||||
# Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make`
|
||||
@@ -18,27 +19,27 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2024.5.1b
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.5.1b
|
||||
# [docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2024.6.2c
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.6.2c
|
||||
# [docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375
|
||||
# [docker.io/vaultwarden/web-vault:v2024.5.1b]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b
|
||||
# [docker.io/vaultwarden/web-vault:v2024.6.2c]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375 as vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b AS vault
|
||||
|
||||
########################## ALPINE BUILD IMAGES ##########################
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
||||
## 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.79.0 as build_amd64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.79.0 as build_arm64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.79.0 as build_armv7
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.79.0 as build_armv6
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.83.0 AS build_amd64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.83.0 AS build_arm64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.83.0 AS build_armv7
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.83.0 AS build_armv6
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} as build
|
||||
FROM --platform=linux/amd64 build_${TARGETARCH}${TARGETVARIANT} AS build
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG TARGETPLATFORM
|
||||
@@ -125,7 +126,7 @@ RUN source /env-cargo && \
|
||||
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
#
|
||||
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.20
|
||||
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.21
|
||||
|
||||
ENV ROCKET_PROFILE="release" \
|
||||
ROCKET_ADDRESS=0.0.0.0 \
|
||||
@@ -142,7 +143,6 @@ RUN mkdir /data && \
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 80
|
||||
EXPOSE 3012
|
||||
|
||||
# Copies the files from the context (Rocket.toml file and web-vault)
|
||||
# and the binary from the "build" stage to the current stage
|
||||
|
@@ -1,4 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform
|
||||
|
||||
# This file was generated using a Jinja2 template.
|
||||
# Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make`
|
||||
@@ -18,24 +19,24 @@
|
||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||
# click the tag name to view the digest of the image it currently points to.
|
||||
# - From the command line:
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2024.5.1b
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.5.1b
|
||||
# [docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375]
|
||||
# $ docker pull docker.io/vaultwarden/web-vault:v2024.6.2c
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2024.6.2c
|
||||
# [docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b]
|
||||
#
|
||||
# - Conversely, to get the tag name from the digest:
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375
|
||||
# [docker.io/vaultwarden/web-vault:v2024.5.1b]
|
||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b
|
||||
# [docker.io/vaultwarden/web-vault:v2024.6.2c]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375 as vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:409ab328ca931439cb916b388a4bb784bd44220717aaf74cf71620c23e34fc2b AS vault
|
||||
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||
## And these bash scripts do not have any significant difference if at all
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:0cd3f05c72d6c9b038eb135f91376ee1169ef3a330d34e418e65e2a5c2e9c0d4 AS xx
|
||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:1978e7a58a1777cb0ef0dde76bad60b7914b21da57cfa88047875e4f364297aa AS xx
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.79.0-slim-bookworm as build
|
||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.83.0-slim-bookworm AS build
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -185,7 +186,6 @@ RUN mkdir /data && \
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 80
|
||||
EXPOSE 3012
|
||||
|
||||
# Copies the files from the context (Rocket.toml file and web-vault)
|
||||
# and the binary from the "build" stage to the current stage
|
||||
|
@@ -1,4 +1,5 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform
|
||||
|
||||
# This file was generated using a Jinja2 template.
|
||||
# Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make`
|
||||
@@ -26,7 +27,7 @@
|
||||
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
|
||||
# [docker.io/vaultwarden/web-vault:{{ vault_version }}]
|
||||
#
|
||||
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" %}
|
||||
########################## Cross Compile Docker Helper Scripts ##########################
|
||||
@@ -38,13 +39,13 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx
|
||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64
|
||||
## 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 %}
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} as build_{{ arch }}
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
########################## BUILD IMAGE ##########################
|
||||
# hadolint ignore=DL3006
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} as build
|
||||
FROM --platform={{ build_stage_image[base].platform }} {{ build_stage_image[base].image }} AS build
|
||||
{% if base == "debian" %}
|
||||
COPY --from=xx / /
|
||||
{% endif %}
|
||||
@@ -229,7 +230,6 @@ RUN mkdir /data && \
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 80
|
||||
EXPOSE 3012
|
||||
|
||||
# Copies the files from the context (Rocket.toml file and web-vault)
|
||||
# and the binary from the "build" stage to the current stage
|
||||
|
@@ -46,7 +46,7 @@ There also is an option to use an other docker container to provide support for
|
||||
```bash
|
||||
# To install and activate
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64,arm
|
||||
# To unistall
|
||||
# To uninstall
|
||||
docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
|
||||
```
|
||||
|
||||
|
@@ -17,7 +17,7 @@ variable "SOURCE_REPOSITORY_URL" {
|
||||
default = null
|
||||
}
|
||||
|
||||
// The commit hash of of the current commit this build was triggered on
|
||||
// The commit hash of the current commit this build was triggered on
|
||||
variable "SOURCE_COMMIT" {
|
||||
default = null
|
||||
}
|
||||
|
@@ -1,5 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ -n "${UMASK}" ]; then
|
||||
umask "${UMASK}"
|
||||
fi
|
||||
|
||||
if [ -r /etc/vaultwarden.sh ]; then
|
||||
. /etc/vaultwarden.sh
|
||||
elif [ -r /etc/bitwarden_rs.sh ]; then
|
||||
|
@@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE twofactor_duo_ctx (
|
||||
state VARCHAR(64) NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
nonce VARCHAR(64) NOT NULL,
|
||||
exp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (state)
|
||||
);
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`;
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser
|
@@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE twofactor_duo_ctx (
|
||||
state VARCHAR(64) NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
nonce VARCHAR(64) NOT NULL,
|
||||
exp BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (state)
|
||||
);
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE twofactor_incomplete DROP COLUMN device_type;
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser
|
@@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE twofactor_duo_ctx (
|
||||
state TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
exp INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (state)
|
||||
);
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`;
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser
|
78
resources/vaultwarden-logo-auto.svg
Normal file
78
resources/vaultwarden-logo-auto.svg
Normal file
@@ -0,0 +1,78 @@
|
||||
<svg width="1365.8256" height="280.48944" version="1.1" viewBox="0 0 1365.8255 280.48944" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
svg { -webkit-filter:invert(0.90); filter:invert(0.90); }
|
||||
}</style>
|
||||
<title>Vaultwarden Logo</title>
|
||||
<defs>
|
||||
<mask id="d">
|
||||
<rect x="-60" y="-60" width="120" height="120" fill="#fff"/>
|
||||
<circle id="b" cy="-40" r="3"/>
|
||||
<use transform="rotate(72)" xlink:href="#b"/>
|
||||
<use transform="rotate(144)" xlink:href="#b"/>
|
||||
<use transform="rotate(216)" xlink:href="#b"/>
|
||||
<use transform="rotate(-72)" xlink:href="#b"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g transform="translate(-10.708266,-9.2965379)" aria-label="aultwarden">
|
||||
<path d="m371.55338 223.43649-5.76172-14.84375h-0.78125q-7.51953 9.47266-15.52735 13.1836-7.91015 3.61328-20.70312 3.61328-15.72266 0-24.80469-8.98438-8.98437-8.98437-8.98437-25.58593 0-17.38282 12.10937-25.58594 12.20703-8.30078 36.71875-9.17969l18.94531-0.58594v-4.78515q0-16.60157-16.99218-16.60157-13.08594 0-30.76172 7.91016l-9.86328-20.11719q18.84765-9.86328 41.79687-9.86328 21.97266 0 33.69141 9.57031 11.71875 9.57032 11.71875 29.10157v72.7539zm-8.78907-50.58593-11.52343 0.39062q-12.98829 0.39063-19.33594 4.6875-6.34766 4.29688-6.34766 13.08594 0 12.59765 14.45313 12.59765 10.35156 0 16.5039-5.95703 6.25-5.95703 6.25-15.82031zm137.59766 50.58593-4.00391-13.96484h-1.5625q-4.78515 7.61719-13.57422 11.81641-8.78906 4.10156-20.01953 4.10156-19.23828 0-29.0039-10.25391-9.76563-10.35156-9.76563-29.6875v-71.1914h29.78516v63.76953q0 11.8164 4.19922 17.77343 4.19922 5.85938 13.3789 5.85938 12.5 0 18.06641-8.30078 5.56641-8.39844 5.56641-27.73438v-51.36718h29.78515v109.17968zm83.88672 0h-29.78516v-151.953122h29.78516zm77.24609-21.77734q7.8125 0 18.75-3.41797v22.16797q-11.13281 4.98047-27.34375 4.98047-17.87109 0-26.07422-8.98438-8.10547-9.08203-8.10547-27.14843v-52.63672h-14.25781v-12.59766l16.40625-9.96094 8.59375-23.046872h19.04297v23.242192h30.56641v22.36328h-30.56641v52.63672q0 6.34765 3.51563 9.375 3.61328 3.02734 9.47265 3.02734z"/>
|
||||
<path d="m791.27994 223.43649-19.62891-62.79297q-1.85547-5.76171-6.93359-26.17187h-0.78125q-3.90625 17.08984-6.83594 26.36719l-20.21484 62.59765h-18.75l-29.19922-107.03125h16.99219q10.35156 40.33203 15.72265 61.42578 5.46875 21.09375 6.25 28.41797h0.78125q1.07422-5.5664 3.41797-14.35547 2.44141-8.88671 4.19922-14.0625l19.62891-61.42578h17.57812l19.14063 61.42578q5.46875 16.79688 7.42187 28.22266h0.78125q0.39063-3.51562 2.05078-10.83984 1.75781-7.32422 20.41016-78.8086h16.79687l-29.58984 107.03125zm133.98437 0-3.22265-15.23437h-0.78125q-8.00782 10.05859-16.01563 13.67187-7.91015 3.51563-19.82422 3.51563-15.91797 0-25-8.20313-8.98437-8.20312-8.98437-23.33984 0-32.42188 51.85547-33.98438l18.16406-0.58593v-6.64063q0-12.59765-5.46875-18.55469-5.37109-6.05468-17.28516-6.05468-13.3789 0-30.27343 8.20312l-4.98047-12.40234q7.91015-4.29688 17.28515-6.73828 9.47266-2.44141 18.94532-2.44141 19.14062 0 28.32031 8.49609 9.27734 8.4961 9.27734 27.2461v73.04687zm-36.62109-11.42578q15.13672 0 23.73047-8.30078 8.6914-8.30078 8.6914-23.24219v-9.66797l-16.21093 0.6836q-19.33594 0.68359-27.92969 6.05469-8.49609 5.27343-8.49609 16.5039 0 8.78906 5.27343 13.37891 5.3711 4.58984 14.94141 4.58984zm130.85938-97.55859q7.1289 0 12.793 1.17187l-2.2461 15.03907q-6.6407-1.46485-11.7188-1.46485-12.9883 0-22.26561 10.54688-9.17968 10.54687-9.17968 26.26953v57.42187h-16.21094v-107.03125h13.37891l1.85546 19.82422h0.78125q5.95704-10.44922 14.35551-16.11328 8.3984-5.66406 18.457-5.66406zm101.6602 94.6289h-0.879q-11.2304 16.3086-33.5937 16.3086-20.9961 0-32.7148-14.35547-11.6211-14.35547-11.6211-40.82031 0-26.46485 11.7187-41.11328 11.7188-14.64844 32.6172-14.64844 21.7773 0 33.3984 15.82031h1.2696l-0.6836-7.71484-0.3907-7.51953v-43.554692h16.211v151.953122h-13.1836zm-32.4219 2.73438q16.6015 0 24.0234-8.98438 7.5195-9.08203 7.5195-29.19921v-3.41797q0-22.75391-7.6171-32.42188-7.5196-9.76562-24.1211-9.76562-14.2578 0-21.875 11.13281-7.5196 11.03516-7.5196 31.25 0 20.50781 7.5196 30.95703 7.5195 10.44922 22.0703 10.44922zm127.3437 13.57422q-23.7304 0-37.5-14.45313-13.6718-14.45312-13.6718-40.13672 0-25.8789 12.6953-41.11328 12.7929-15.23437 34.2773-15.23437 20.1172 0 31.8359 13.28125 11.7188 13.18359 11.7188 34.86328v10.25391h-73.7305q0.4883 18.84765 9.4727 28.61328 9.082 9.76562 25.4883 9.76562 17.2851 0 34.1797-7.22656v14.45312q-8.5938 3.71094-16.3086 5.27344-7.6172 1.66016-18.4571 1.66016zm-4.3945-97.36328q-12.8906 0-20.6055 8.39843-7.6172 8.39844-8.9843 23.24219h55.957q0-15.33203-6.836-23.4375-6.8359-8.20312-19.5312-8.20312zm144.6289 95.41015v-69.23828q0-13.08594-5.957-19.53125-5.9571-6.44531-18.6524-6.44531-16.7968 0-24.6093 9.08203t-7.8125 29.98047v56.15234h-16.211v-107.03125h13.1836l2.6367 14.64844h0.7813q4.9804-7.91016 13.9648-12.20703 8.9844-4.39453 20.0196-4.39453 19.3359 0 29.1015 9.375 9.7656 9.27734 9.7656 29.78515v69.82422z"/>
|
||||
</g>
|
||||
<g transform="translate(-10.708266,-9.2965379)">
|
||||
<g id="e" transform="matrix(2.6712834,0,0,2.6712834,150.95027,149.53854)">
|
||||
<g id="f" mask="url(#d)">
|
||||
<path d="m-31.1718-33.813208 26.496029 74.188883h9.3515399l26.49603-74.188883h-9.767164l-16.728866 47.588948q-1.662496 4.571864-2.805462 8.624198-1.142966 3.948427-1.870308 7.585137-.72734199-3.63671-1.8703079-7.689043-1.142966-4.052334-2.805462-8.728104l-16.624959-47.381136z" stroke="#000" stroke-width="4.51171"/>
|
||||
<circle transform="scale(-1,1)" r="43" fill="none" stroke="#000" stroke-width="9"/>
|
||||
<g id="g" transform="scale(-1,1)">
|
||||
<polygon id="a" points="46 -3 46 3 51 0" stroke="#000" stroke-linejoin="round" stroke-width="3"/>
|
||||
<use transform="rotate(11.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(22.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(33.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(45)" xlink:href="#a"/>
|
||||
<use transform="rotate(56.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(67.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(78.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(90)" xlink:href="#a"/>
|
||||
<use transform="rotate(101.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(112.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(123.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(135)" xlink:href="#a"/>
|
||||
<use transform="rotate(146.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(157.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(168.75)" xlink:href="#a"/>
|
||||
<use transform="scale(-1)" xlink:href="#a"/>
|
||||
<use transform="rotate(191.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(202.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(213.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(225)" xlink:href="#a"/>
|
||||
<use transform="rotate(236.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(247.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(258.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(-90)" xlink:href="#a"/>
|
||||
<use transform="rotate(-78.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(-67.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(-56.25)" xlink:href="#a"/>
|
||||
<use transform="rotate(-45)" xlink:href="#a"/>
|
||||
<use transform="rotate(-33.75)" xlink:href="#a"/>
|
||||
<use transform="rotate(-22.5)" xlink:href="#a"/>
|
||||
<use transform="rotate(-11.25)" xlink:href="#a"/>
|
||||
</g>
|
||||
<g id="h" transform="scale(-1,1)">
|
||||
<polygon id="c" points="7 -42 -7 -42 0 -35" stroke="#000" stroke-linejoin="round" stroke-width="6"/>
|
||||
<use transform="rotate(72)" xlink:href="#c"/>
|
||||
<use transform="rotate(144)" xlink:href="#c"/>
|
||||
<use transform="rotate(216)" xlink:href="#c"/>
|
||||
<use transform="rotate(-72)" xlink:href="#c"/>
|
||||
</g>
|
||||
</g>
|
||||
<mask>
|
||||
<rect x="-60" y="-60" width="120" height="120" fill="#fff"/>
|
||||
<circle cy="-40" r="3"/>
|
||||
<use transform="rotate(72)" xlink:href="#b"/>
|
||||
<use transform="rotate(144)" xlink:href="#b"/>
|
||||
<use transform="rotate(216)" xlink:href="#b"/>
|
||||
<use transform="rotate(-72)" xlink:href="#b"/>
|
||||
</mask>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.79.0"
|
||||
channel = "1.83.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
@@ -1,4 +1,5 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
@@ -17,13 +18,14 @@ use crate::{
|
||||
core::{log_event, two_factor},
|
||||
unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify,
|
||||
},
|
||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp},
|
||||
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure},
|
||||
config::ConfigBuilder,
|
||||
db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType},
|
||||
error::{Error, MapResult},
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
util::{
|
||||
container_base_image, format_naive_datetime_local, get_display_size, get_reqwest_client,
|
||||
container_base_image, format_naive_datetime_local, get_display_size, get_web_vault_version,
|
||||
is_running_in_container, NumberOrString,
|
||||
},
|
||||
CONFIG, VERSION,
|
||||
@@ -60,6 +62,7 @@ pub fn routes() -> Vec<Route> {
|
||||
diagnostics,
|
||||
get_diagnostics_config,
|
||||
resend_user_invite,
|
||||
get_diagnostics_http,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -168,7 +171,12 @@ struct LoginForm {
|
||||
}
|
||||
|
||||
#[post("/", data = "<data>")]
|
||||
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> Result<Redirect, AdminResponse> {
|
||||
fn post_admin_login(
|
||||
data: Form<LoginForm>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ip: ClientIp,
|
||||
secure: Secure,
|
||||
) -> Result<Redirect, AdminResponse> {
|
||||
let data = data.into_inner();
|
||||
let redirect = data.redirect;
|
||||
|
||||
@@ -190,9 +198,10 @@ fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp
|
||||
|
||||
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
||||
.path(admin_path())
|
||||
.max_age(rocket::time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
||||
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
||||
.same_site(SameSite::Strict)
|
||||
.http_only(true);
|
||||
.http_only(true)
|
||||
.secure(secure.https);
|
||||
|
||||
cookies.add(cookie);
|
||||
if let Some(redirect) = redirect {
|
||||
@@ -290,7 +299,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
||||
|
||||
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_invite(&user.email, &user.uuid, None, None, &CONFIG.invitation_org_name(), None).await
|
||||
mail::send_invite(user, None, None, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
let invitation = Invitation::new(&user.email);
|
||||
invitation.save(conn).await
|
||||
@@ -466,7 +475,7 @@ async fn resend_user_invite(uuid: &str, _token: AdminToken, mut conn: DbConn) ->
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_invite(&user.email, &user.uuid, None, None, &CONFIG.invitation_org_name(), None).await
|
||||
mail::send_invite(&user, None, None, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -486,11 +495,11 @@ struct UserOrgTypeData {
|
||||
async fn update_user_org_type(data: Json<UserOrgTypeData>, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let data: UserOrgTypeData = data.into_inner();
|
||||
|
||||
let mut user_to_edit =
|
||||
match UserOrganization::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("The specified user isn't member of the organization"),
|
||||
};
|
||||
let Some(mut user_to_edit) =
|
||||
UserOrganization::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &mut conn).await
|
||||
else {
|
||||
err!("The specified user isn't member of the organization")
|
||||
};
|
||||
|
||||
let new_type = match UserOrgType::from_str(&data.user_type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
@@ -568,11 +577,6 @@ async fn delete_organization(uuid: &str, _token: AdminToken, mut conn: DbConn) -
|
||||
org.delete(&mut conn).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebVaultVersion {
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitRelease {
|
||||
tag_name: String,
|
||||
@@ -594,15 +598,14 @@ struct TimeApi {
|
||||
}
|
||||
|
||||
async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
|
||||
let json_api = get_reqwest_client();
|
||||
|
||||
Ok(json_api.get(url).send().await?.error_for_status()?.json::<T>().await?)
|
||||
Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::<T>().await?)
|
||||
}
|
||||
|
||||
async fn has_http_access() -> bool {
|
||||
let http_access = get_reqwest_client();
|
||||
|
||||
match http_access.head("https://github.com/dani-garcia/vaultwarden").send().await {
|
||||
let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else {
|
||||
return false;
|
||||
};
|
||||
match req.send().await {
|
||||
Ok(r) => r.status().is_success(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -672,18 +675,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
||||
use chrono::prelude::*;
|
||||
use std::net::ToSocketAddrs;
|
||||
|
||||
// Get current running versions
|
||||
let web_vault_version: WebVaultVersion =
|
||||
match std::fs::read_to_string(format!("{}/{}", CONFIG.web_vault_folder(), "vw-version.json")) {
|
||||
Ok(s) => serde_json::from_str(&s)?,
|
||||
_ => match std::fs::read_to_string(format!("{}/{}", CONFIG.web_vault_folder(), "version.json")) {
|
||||
Ok(s) => serde_json::from_str(&s)?,
|
||||
_ => WebVaultVersion {
|
||||
version: String::from("Version file missing"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Execute some environment checks
|
||||
let running_within_container = is_running_in_container();
|
||||
let has_http_access = has_http_access().await;
|
||||
@@ -703,13 +694,16 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
||||
|
||||
let ip_header_name = &ip_header.0.unwrap_or_default();
|
||||
|
||||
// Get current running versions
|
||||
let web_vault_version = get_web_vault_version();
|
||||
|
||||
let diagnostics_json = json!({
|
||||
"dns_resolved": dns_resolved,
|
||||
"current_release": VERSION,
|
||||
"latest_release": latest_release,
|
||||
"latest_commit": latest_commit,
|
||||
"web_vault_enabled": &CONFIG.web_vault_enabled(),
|
||||
"web_vault_version": web_vault_version.version.trim_start_matches('v'),
|
||||
"web_vault_version": web_vault_version,
|
||||
"latest_web_build": latest_web_build,
|
||||
"running_within_container": running_within_container,
|
||||
"container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
|
||||
@@ -719,12 +713,13 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
||||
"ip_header_name": ip_header_name,
|
||||
"ip_header_config": &CONFIG.ip_header(),
|
||||
"uses_proxy": uses_proxy,
|
||||
"enable_websocket": &CONFIG.enable_websocket(),
|
||||
"db_type": *DB_TYPE,
|
||||
"db_version": get_sql_server_version(&mut conn).await,
|
||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||
"overrides": &CONFIG.get_overrides().join(", "),
|
||||
"host_arch": std::env::consts::ARCH,
|
||||
"host_os": std::env::consts::OS,
|
||||
"host_arch": env::consts::ARCH,
|
||||
"host_os": env::consts::OS,
|
||||
"server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(),
|
||||
"server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the server date/time check as late as possible to minimize the time difference
|
||||
"ntp_time": get_ntp_time(has_http_access).await, // Run the ntp check as late as possible to minimize the time difference
|
||||
@@ -740,21 +735,35 @@ fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
|
||||
Json(support_json)
|
||||
}
|
||||
|
||||
#[get("/diagnostics/http?<code>")]
|
||||
fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
|
||||
err_code!(format!("Testing error {code} response"), code);
|
||||
}
|
||||
|
||||
#[post("/config", data = "<data>")]
|
||||
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
|
||||
let data: ConfigBuilder = data.into_inner();
|
||||
CONFIG.update_config(data)
|
||||
if let Err(e) = CONFIG.update_config(data) {
|
||||
err!(format!("Unable to save config: {e:?}"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/config/delete")]
|
||||
fn delete_config(_token: AdminToken) -> EmptyResult {
|
||||
CONFIG.delete_user_config()
|
||||
if let Err(e) = CONFIG.delete_user_config() {
|
||||
err!(format!("Unable to delete config: {e:?}"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/config/backup_db")]
|
||||
async fn backup_db(_token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
async fn backup_db(_token: AdminToken, mut conn: DbConn) -> ApiResult<String> {
|
||||
if *CAN_BACKUP {
|
||||
backup_database(&mut conn).await
|
||||
match backup_database(&mut conn).await {
|
||||
Ok(f) => Ok(format!("Backup to '{f}' was successful")),
|
||||
Err(e) => err!(format!("Backup was unsuccessful {e}")),
|
||||
}
|
||||
} else {
|
||||
err!("Can't back up current DB (Only SQLite supports this feature)");
|
||||
}
|
||||
|
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::db::DbPool;
|
||||
use chrono::Utc;
|
||||
use rocket::serde::json::Json;
|
||||
@@ -13,7 +15,7 @@ use crate::{
|
||||
crypto,
|
||||
db::{models::*, DbConn},
|
||||
mail,
|
||||
util::NumberOrString,
|
||||
util::{format_date, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -112,7 +114,7 @@ async fn is_email_2fa_required(org_user_uuid: Option<String>, conn: &mut DbConn)
|
||||
return true;
|
||||
}
|
||||
if org_user_uuid.is_some() {
|
||||
return OrgPolicy::is_enabled_by_org(&org_user_uuid.unwrap(), OrgPolicyType::TwoFactorAuthentication, conn)
|
||||
return OrgPolicy::is_enabled_for_member(&org_user_uuid.unwrap(), OrgPolicyType::TwoFactorAuthentication, conn)
|
||||
.await;
|
||||
}
|
||||
false
|
||||
@@ -223,7 +225,7 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
|
||||
}
|
||||
|
||||
if verified_by_invite && is_email_2fa_required(data.organization_user_id, &mut conn).await {
|
||||
let _ = email::activate_email_2fa(&user, &mut conn).await;
|
||||
email::activate_email_2fa(&user, &mut conn).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +234,7 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
|
||||
// accept any open emergency access invitations
|
||||
if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() {
|
||||
for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &mut conn).await {
|
||||
let _ = emergency_invite.accept_invite(&user.uuid, &user.email, &mut conn).await;
|
||||
emergency_invite.accept_invite(&user.uuid, &user.email, &mut conn).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +479,60 @@ struct KeyData {
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
fn validate_keydata(
|
||||
data: &KeyData,
|
||||
existing_ciphers: &[Cipher],
|
||||
existing_folders: &[Folder],
|
||||
existing_emergency_access: &[EmergencyAccess],
|
||||
existing_user_orgs: &[UserOrganization],
|
||||
existing_sends: &[Send],
|
||||
) -> EmptyResult {
|
||||
// Check that we're correctly rotating all the user's ciphers
|
||||
let existing_cipher_ids = existing_ciphers.iter().map(|c| c.uuid.as_str()).collect::<HashSet<_>>();
|
||||
let provided_cipher_ids = data
|
||||
.ciphers
|
||||
.iter()
|
||||
.filter(|c| c.organization_id.is_none())
|
||||
.filter_map(|c| c.id.as_deref())
|
||||
.collect::<HashSet<_>>();
|
||||
if !provided_cipher_ids.is_superset(&existing_cipher_ids) {
|
||||
err!("All existing ciphers must be included in the rotation")
|
||||
}
|
||||
|
||||
// Check that we're correctly rotating all the user's folders
|
||||
let existing_folder_ids = existing_folders.iter().map(|f| f.uuid.as_str()).collect::<HashSet<_>>();
|
||||
let provided_folder_ids = data.folders.iter().filter_map(|f| f.id.as_deref()).collect::<HashSet<_>>();
|
||||
if !provided_folder_ids.is_superset(&existing_folder_ids) {
|
||||
err!("All existing folders must be included in the rotation")
|
||||
}
|
||||
|
||||
// Check that we're correctly rotating all the user's emergency access keys
|
||||
let existing_emergency_access_ids =
|
||||
existing_emergency_access.iter().map(|ea| ea.uuid.as_str()).collect::<HashSet<_>>();
|
||||
let provided_emergency_access_ids =
|
||||
data.emergency_access_keys.iter().map(|ea| ea.id.as_str()).collect::<HashSet<_>>();
|
||||
if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) {
|
||||
err!("All existing emergency access keys must be included in the rotation")
|
||||
}
|
||||
|
||||
// Check that we're correctly rotating all the user's reset password keys
|
||||
let existing_reset_password_ids = existing_user_orgs.iter().map(|uo| uo.org_uuid.as_str()).collect::<HashSet<_>>();
|
||||
let provided_reset_password_ids =
|
||||
data.reset_password_keys.iter().map(|rp| rp.organization_id.as_str()).collect::<HashSet<_>>();
|
||||
if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) {
|
||||
err!("All existing reset password keys must be included in the rotation")
|
||||
}
|
||||
|
||||
// Check that we're correctly rotating all the user's sends
|
||||
let existing_send_ids = existing_sends.iter().map(|s| s.uuid.as_str()).collect::<HashSet<_>>();
|
||||
let provided_send_ids = data.sends.iter().filter_map(|s| s.id.as_deref()).collect::<HashSet<_>>();
|
||||
if !provided_send_ids.is_superset(&existing_send_ids) {
|
||||
err!("All existing sends must be included in the rotation")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/accounts/key", data = "<data>")]
|
||||
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
// TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
|
||||
@@ -490,24 +546,38 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
|
||||
// Bitwarden does not process the import if there is one item invalid.
|
||||
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
|
||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||
Cipher::validate_notes(&data.ciphers)?;
|
||||
Cipher::validate_cipher_data(&data.ciphers)?;
|
||||
|
||||
let user_uuid = &headers.user.uuid;
|
||||
|
||||
// TODO: Ideally we'd do everything after this point in a single transaction.
|
||||
|
||||
let mut existing_ciphers = Cipher::find_owned_by_user(user_uuid, &mut conn).await;
|
||||
let mut existing_folders = Folder::find_by_user(user_uuid, &mut conn).await;
|
||||
let mut existing_emergency_access = EmergencyAccess::find_all_by_grantor_uuid(user_uuid, &mut conn).await;
|
||||
let mut existing_user_orgs = UserOrganization::find_by_user(user_uuid, &mut conn).await;
|
||||
// We only rotate the reset password key if it is set.
|
||||
existing_user_orgs.retain(|uo| uo.reset_password_key.is_some());
|
||||
let mut existing_sends = Send::find_by_user(user_uuid, &mut conn).await;
|
||||
|
||||
validate_keydata(
|
||||
&data,
|
||||
&existing_ciphers,
|
||||
&existing_folders,
|
||||
&existing_emergency_access,
|
||||
&existing_user_orgs,
|
||||
&existing_sends,
|
||||
)?;
|
||||
|
||||
// Update folder data
|
||||
for folder_data in data.folders {
|
||||
// Skip `null` folder id entries.
|
||||
// See: https://github.com/bitwarden/clients/issues/8453
|
||||
if let Some(folder_id) = folder_data.id {
|
||||
let mut saved_folder = match Folder::find_by_uuid(&folder_id, &mut conn).await {
|
||||
Some(folder) => folder,
|
||||
None => err!("Folder doesn't exist"),
|
||||
let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else {
|
||||
err!("Folder doesn't exist")
|
||||
};
|
||||
|
||||
if &saved_folder.user_uuid != user_uuid {
|
||||
err!("The folder is not owned by the user")
|
||||
}
|
||||
|
||||
saved_folder.name = folder_data.name;
|
||||
saved_folder.save(&mut conn).await?
|
||||
}
|
||||
@@ -515,12 +585,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
|
||||
|
||||
// Update emergency access data
|
||||
for emergency_access_data in data.emergency_access_keys {
|
||||
let mut saved_emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emergency_access_data.id, user_uuid, &mut conn).await
|
||||
{
|
||||
Some(emergency_access) => emergency_access,
|
||||
None => err!("Emergency access doesn't exist or is not owned by the user"),
|
||||
};
|
||||
let Some(saved_emergency_access) =
|
||||
existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id)
|
||||
else {
|
||||
err!("Emergency access doesn't exist or is not owned by the user")
|
||||
};
|
||||
|
||||
saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted);
|
||||
saved_emergency_access.save(&mut conn).await?
|
||||
@@ -528,13 +597,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
|
||||
|
||||
// Update reset password data
|
||||
for reset_password_data in data.reset_password_keys {
|
||||
let mut user_org =
|
||||
match UserOrganization::find_by_user_and_org(user_uuid, &reset_password_data.organization_id, &mut conn)
|
||||
.await
|
||||
{
|
||||
Some(reset_password) => reset_password,
|
||||
None => err!("Reset password doesn't exist"),
|
||||
};
|
||||
let Some(user_org) =
|
||||
existing_user_orgs.iter_mut().find(|uo| uo.org_uuid == reset_password_data.organization_id)
|
||||
else {
|
||||
err!("Reset password doesn't exist")
|
||||
};
|
||||
|
||||
user_org.reset_password_key = Some(reset_password_data.reset_password_key);
|
||||
user_org.save(&mut conn).await?
|
||||
@@ -542,12 +609,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
|
||||
|
||||
// Update send data
|
||||
for send_data in data.sends {
|
||||
let mut send = match Send::find_by_uuid(send_data.id.as_ref().unwrap(), &mut conn).await {
|
||||
Some(send) => send,
|
||||
None => err!("Send doesn't exist"),
|
||||
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {
|
||||
err!("Send doesn't exist")
|
||||
};
|
||||
|
||||
update_send_from_data(&mut send, send_data, &headers, &mut conn, &nt, UpdateType::None).await?;
|
||||
update_send_from_data(send, send_data, &headers, &mut conn, &nt, UpdateType::None).await?;
|
||||
}
|
||||
|
||||
// Update cipher data
|
||||
@@ -555,20 +621,15 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
|
||||
|
||||
for cipher_data in data.ciphers {
|
||||
if cipher_data.organization_id.is_none() {
|
||||
let mut saved_cipher = match Cipher::find_by_uuid(cipher_data.id.as_ref().unwrap(), &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap())
|
||||
else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if saved_cipher.user_uuid.as_ref().unwrap() != user_uuid {
|
||||
err!("The cipher is not owned by the user")
|
||||
}
|
||||
|
||||
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None
|
||||
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
|
||||
// We force the users to logout after the user has been saved to try and prevent these issues.
|
||||
update_cipher_from_data(&mut saved_cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None)
|
||||
.await?
|
||||
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,14 +800,12 @@ struct VerifyEmailTokenData {
|
||||
async fn post_verify_email_token(data: Json<VerifyEmailTokenData>, mut conn: DbConn) -> EmptyResult {
|
||||
let data: VerifyEmailTokenData = data.into_inner();
|
||||
|
||||
let mut user = match User::find_by_uuid(&data.user_id, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("User doesn't exist"),
|
||||
let Some(mut user) = User::find_by_uuid(&data.user_id, &mut conn).await else {
|
||||
err!("User doesn't exist")
|
||||
};
|
||||
|
||||
let claims = match decode_verify_email(&data.token) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => err!("Invalid claim"),
|
||||
let Ok(claims) = decode_verify_email(&data.token) else {
|
||||
err!("Invalid claim")
|
||||
};
|
||||
if claims.sub != user.uuid {
|
||||
err!("Invalid claim");
|
||||
@@ -798,15 +857,14 @@ struct DeleteRecoverTokenData {
|
||||
async fn post_delete_recover_token(data: Json<DeleteRecoverTokenData>, mut conn: DbConn) -> EmptyResult {
|
||||
let data: DeleteRecoverTokenData = data.into_inner();
|
||||
|
||||
let user = match User::find_by_uuid(&data.user_id, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("User doesn't exist"),
|
||||
let Ok(claims) = decode_delete(&data.token) else {
|
||||
err!("Invalid claim")
|
||||
};
|
||||
|
||||
let claims = match decode_delete(&data.token) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => err!("Invalid claim"),
|
||||
let Some(user) = User::find_by_uuid(&data.user_id, &mut conn).await else {
|
||||
err!("User doesn't exist")
|
||||
};
|
||||
|
||||
if claims.sub != user.uuid {
|
||||
err!("Invalid claim");
|
||||
}
|
||||
@@ -842,7 +900,7 @@ struct PasswordHintData {
|
||||
|
||||
#[post("/accounts/password-hint", data = "<data>")]
|
||||
async fn password_hint(data: Json<PasswordHintData>, mut conn: DbConn) -> EmptyResult {
|
||||
if !CONFIG.mail_enabled() && !CONFIG.show_password_hint() {
|
||||
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
|
||||
err!("This server is not configured to provide password hints.");
|
||||
}
|
||||
|
||||
@@ -901,14 +959,12 @@ pub async fn _prelogin(data: Json<PreloginData>, mut conn: DbConn) -> Json<Value
|
||||
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
Json(json!({
|
||||
"kdf": kdf_type,
|
||||
"kdfIterations": kdf_iter,
|
||||
"kdfMemory": kdf_mem,
|
||||
"kdfParallelism": kdf_para,
|
||||
});
|
||||
|
||||
Json(result)
|
||||
}))
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs
|
||||
@@ -980,11 +1036,8 @@ impl<'r> FromRequest<'r> for KnownDevice {
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
|
||||
let email_bytes = match data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => {
|
||||
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
||||
}
|
||||
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
|
||||
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
|
||||
};
|
||||
match String::from_utf8(email_bytes) {
|
||||
Ok(email) => email,
|
||||
@@ -1025,9 +1078,9 @@ async fn put_device_token(uuid: &str, data: Json<PushToken>, headers: Headers, m
|
||||
let data = data.into_inner();
|
||||
let token = data.push_token;
|
||||
|
||||
let mut device = match Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &mut conn).await {
|
||||
Some(device) => device,
|
||||
None => err!(format!("Error: device {uuid} should be present before a token can be assigned")),
|
||||
let Some(mut device) = Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!(format!("Error: device {uuid} should be present before a token can be assigned"))
|
||||
};
|
||||
|
||||
// if the device already has been registered
|
||||
@@ -1038,7 +1091,7 @@ async fn put_device_token(uuid: &str, data: Json<PushToken>, headers: Headers, m
|
||||
return Ok(());
|
||||
} else {
|
||||
// Try to unregister already registered device
|
||||
let _ = unregister_push_device(device.push_uuid).await;
|
||||
unregister_push_device(device.push_uuid).await.ok();
|
||||
}
|
||||
// clear the push_uuid
|
||||
device.push_uuid = None;
|
||||
@@ -1084,31 +1137,35 @@ struct AuthRequestRequest {
|
||||
device_identifier: String,
|
||||
email: String,
|
||||
public_key: String,
|
||||
#[serde(alias = "type")]
|
||||
_type: i32,
|
||||
// Not used for now
|
||||
// #[serde(alias = "type")]
|
||||
// _type: i32,
|
||||
}
|
||||
|
||||
#[post("/auth-requests", data = "<data>")]
|
||||
async fn post_auth_request(
|
||||
data: Json<AuthRequestRequest>,
|
||||
headers: ClientHeaders,
|
||||
client_headers: ClientHeaders,
|
||||
mut conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let data = data.into_inner();
|
||||
|
||||
let user = match User::find_by_mail(&data.email, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
err!("AuthRequest doesn't exist")
|
||||
}
|
||||
let Some(user) = User::find_by_mail(&data.email, &mut conn).await else {
|
||||
err!("AuthRequest doesn't exist", "User not found")
|
||||
};
|
||||
|
||||
// Validate device uuid and type
|
||||
match Device::find_by_uuid_and_user(&data.device_identifier, &user.uuid, &mut conn).await {
|
||||
Some(device) if device.atype == client_headers.device_type => {}
|
||||
_ => err!("AuthRequest doesn't exist", "Device verification failed"),
|
||||
}
|
||||
|
||||
let mut auth_request = AuthRequest::new(
|
||||
user.uuid.clone(),
|
||||
data.device_identifier.clone(),
|
||||
headers.device_type,
|
||||
headers.ip.ip.to_string(),
|
||||
client_headers.device_type,
|
||||
client_headers.ip.ip.to_string(),
|
||||
data.access_code,
|
||||
data.public_key,
|
||||
);
|
||||
@@ -1123,7 +1180,7 @@ async fn post_auth_request(
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": null,
|
||||
"masterPasswordHash": null,
|
||||
"creationDate": auth_request.creation_date.and_utc(),
|
||||
"creationDate": format_date(&auth_request.creation_date),
|
||||
"responseDate": null,
|
||||
"requestApproved": false,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
@@ -1132,31 +1189,26 @@ async fn post_auth_request(
|
||||
}
|
||||
|
||||
#[get("/auth-requests/<uuid>")]
|
||||
async fn get_auth_request(uuid: &str, mut conn: DbConn) -> JsonResult {
|
||||
let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(auth_request) => auth_request,
|
||||
None => {
|
||||
err!("AuthRequest doesn't exist")
|
||||
}
|
||||
async fn get_auth_request(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
|
||||
};
|
||||
|
||||
let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc());
|
||||
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date));
|
||||
|
||||
Ok(Json(json!(
|
||||
{
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": auth_request.creation_date.and_utc(),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
}
|
||||
)))
|
||||
Ok(Json(json!({
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": format_date(&auth_request.creation_date),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1172,78 +1224,86 @@ struct AuthResponseRequest {
|
||||
async fn put_auth_request(
|
||||
uuid: &str,
|
||||
data: Json<AuthResponseRequest>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
ant: AnonymousNotify<'_>,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let data = data.into_inner();
|
||||
let mut auth_request: AuthRequest = match AuthRequest::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(auth_request) => auth_request,
|
||||
None => {
|
||||
err!("AuthRequest doesn't exist")
|
||||
}
|
||||
let Some(mut auth_request) = AuthRequest::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("AuthRequest doesn't exist", "Record not found or user uuid does not match")
|
||||
};
|
||||
|
||||
auth_request.approved = Some(data.request_approved);
|
||||
auth_request.enc_key = Some(data.key);
|
||||
auth_request.master_password_hash = data.master_password_hash;
|
||||
auth_request.response_device_id = Some(data.device_identifier.clone());
|
||||
auth_request.save(&mut conn).await?;
|
||||
|
||||
if auth_request.approved.unwrap_or(false) {
|
||||
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
|
||||
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.device_identifier, &mut conn).await;
|
||||
if auth_request.approved.is_some() {
|
||||
err!("An authentication request with the same device already exists")
|
||||
}
|
||||
|
||||
let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc());
|
||||
let response_date = Utc::now().naive_utc();
|
||||
let response_date_utc = format_date(&response_date);
|
||||
|
||||
Ok(Json(json!(
|
||||
{
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": auth_request.creation_date.and_utc(),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
}
|
||||
)))
|
||||
if data.request_approved {
|
||||
auth_request.approved = Some(data.request_approved);
|
||||
auth_request.enc_key = Some(data.key);
|
||||
auth_request.master_password_hash = data.master_password_hash;
|
||||
auth_request.response_device_id = Some(data.device_identifier.clone());
|
||||
auth_request.response_date = Some(response_date);
|
||||
auth_request.save(&mut conn).await?;
|
||||
|
||||
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
|
||||
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.device_identifier, &mut conn).await;
|
||||
} else {
|
||||
// If denied, there's no reason to keep the request
|
||||
auth_request.delete(&mut conn).await?;
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": format_date(&auth_request.creation_date),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/auth-requests/<uuid>/response?<code>")]
|
||||
async fn get_auth_request_response(uuid: &str, code: &str, mut conn: DbConn) -> JsonResult {
|
||||
let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(auth_request) => auth_request,
|
||||
None => {
|
||||
err!("AuthRequest doesn't exist")
|
||||
}
|
||||
async fn get_auth_request_response(
|
||||
uuid: &str,
|
||||
code: &str,
|
||||
client_headers: ClientHeaders,
|
||||
mut conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("AuthRequest doesn't exist", "User not found")
|
||||
};
|
||||
|
||||
if !auth_request.check_access_code(code) {
|
||||
err!("Access code invalid doesn't exist")
|
||||
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")
|
||||
}
|
||||
|
||||
let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc());
|
||||
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date));
|
||||
|
||||
Ok(Json(json!(
|
||||
{
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": auth_request.creation_date.and_utc(),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
}
|
||||
)))
|
||||
Ok(Json(json!({
|
||||
"id": uuid,
|
||||
"publicKey": auth_request.public_key,
|
||||
"requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(),
|
||||
"requestIpAddress": auth_request.request_ip,
|
||||
"key": auth_request.enc_key,
|
||||
"masterPasswordHash": auth_request.master_password_hash,
|
||||
"creationDate": format_date(&auth_request.creation_date),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": auth_request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
"object":"auth-request"
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/auth-requests")]
|
||||
@@ -1255,7 +1315,7 @@ async fn get_auth_requests(headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
.iter()
|
||||
.filter(|request| request.approved.is_none())
|
||||
.map(|request| {
|
||||
let response_date_utc = request.response_date.map(|response_date| response_date.and_utc());
|
||||
let response_date_utc = request.response_date.map(|response_date| format_date(&response_date));
|
||||
|
||||
json!({
|
||||
"id": request.uuid,
|
||||
@@ -1264,7 +1324,7 @@ async fn get_auth_requests(headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
"requestIpAddress": request.request_ip,
|
||||
"key": request.enc_key,
|
||||
"masterPasswordHash": request.master_password_hash,
|
||||
"creationDate": request.creation_date.and_utc(),
|
||||
"creationDate": format_date(&request.creation_date),
|
||||
"responseDate": response_date_utc,
|
||||
"requestApproved": request.approved,
|
||||
"origin": CONFIG.domain_origin(),
|
||||
|
@@ -10,6 +10,7 @@ use rocket::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::ClientVersion;
|
||||
use crate::util::NumberOrString;
|
||||
use crate::{
|
||||
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
||||
@@ -104,11 +105,27 @@ struct SyncData {
|
||||
}
|
||||
|
||||
#[get("/sync?<data..>")]
|
||||
async fn sync(data: SyncData, headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
async fn sync(
|
||||
data: SyncData,
|
||||
headers: Headers,
|
||||
client_version: Option<ClientVersion>,
|
||||
mut conn: DbConn,
|
||||
) -> Json<Value> {
|
||||
let user_json = headers.user.to_json(&mut conn).await;
|
||||
|
||||
// Get all ciphers which are visible by the user
|
||||
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await;
|
||||
let mut ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await;
|
||||
|
||||
// Filter out SSH keys if the client version is less than 2024.12.0
|
||||
let show_ssh_keys = if let Some(client_version) = client_version {
|
||||
let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap();
|
||||
ver_match.matches(&client_version.0)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !show_ssh_keys {
|
||||
ciphers.retain(|c| c.atype != 5);
|
||||
}
|
||||
|
||||
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await;
|
||||
|
||||
@@ -150,7 +167,6 @@ async fn sync(data: SyncData, headers: Headers, mut conn: DbConn) -> Json<Value>
|
||||
"ciphers": ciphers_json,
|
||||
"domains": domains_json,
|
||||
"sends": sends_json,
|
||||
"unofficialServer": true,
|
||||
"object": "sync"
|
||||
}))
|
||||
}
|
||||
@@ -177,9 +193,8 @@ async fn get_ciphers(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/ciphers/<uuid>")]
|
||||
async fn get_cipher(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -206,8 +221,9 @@ pub struct CipherData {
|
||||
// Id is optional as it is included only in bulk share
|
||||
pub id: Option<String>,
|
||||
// Folder id is not included in import
|
||||
folder_id: Option<String>,
|
||||
pub folder_id: Option<String>,
|
||||
// TODO: Some of these might appear all the time, no need for Option
|
||||
#[serde(alias = "organizationID")]
|
||||
pub organization_id: Option<String>,
|
||||
|
||||
key: Option<String>,
|
||||
@@ -216,7 +232,8 @@ pub struct CipherData {
|
||||
Login = 1,
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4
|
||||
Identity = 4,
|
||||
SshKey = 5
|
||||
*/
|
||||
pub r#type: i32,
|
||||
pub name: String,
|
||||
@@ -228,11 +245,12 @@ pub struct CipherData {
|
||||
secure_note: Option<Value>,
|
||||
card: Option<Value>,
|
||||
identity: Option<Value>,
|
||||
ssh_key: Option<Value>,
|
||||
|
||||
favorite: Option<bool>,
|
||||
reprompt: Option<i32>,
|
||||
|
||||
password_history: Option<Value>,
|
||||
pub password_history: Option<Value>,
|
||||
|
||||
// These are used during key rotation
|
||||
// 'Attachments' is unused, contains map of {id: filename}
|
||||
@@ -377,8 +395,9 @@ pub async fn update_cipher_from_data(
|
||||
}
|
||||
|
||||
if let Some(note) = &data.notes {
|
||||
if note.len() > 10_000 {
|
||||
err!("The field Notes exceeds the maximum encrypted value length of 10000 characters.")
|
||||
let max_note_size = CONFIG._max_note_size();
|
||||
if note.len() > max_note_size {
|
||||
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,14 +428,9 @@ pub async fn update_cipher_from_data(
|
||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||
}
|
||||
|
||||
if let Some(ref folder_id) = data.folder_id {
|
||||
match Folder::find_by_uuid(folder_id, conn).await {
|
||||
Some(folder) => {
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder is not owned by user")
|
||||
}
|
||||
}
|
||||
None => err!("Folder doesn't exist"),
|
||||
if let Some(ref folder_uuid) = data.folder_id {
|
||||
if Folder::find_by_uuid_and_user(folder_uuid, &headers.user.uuid, conn).await.is_none() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +482,7 @@ pub async fn update_cipher_from_data(
|
||||
2 => data.secure_note,
|
||||
3 => data.card,
|
||||
4 => data.identity,
|
||||
5 => data.ssh_key,
|
||||
_ => err!("Invalid type"),
|
||||
};
|
||||
|
||||
@@ -490,7 +505,7 @@ pub async fn update_cipher_from_data(
|
||||
cipher.fields = data.fields.map(|f| _clean_cipher_data(f).to_string());
|
||||
cipher.data = type_data.to_string();
|
||||
cipher.password_history = data.password_history.map(|f| f.to_string());
|
||||
cipher.reprompt = data.reprompt;
|
||||
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||
|
||||
cipher.save(conn).await?;
|
||||
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
|
||||
@@ -561,14 +576,14 @@ async fn post_ciphers_import(
|
||||
// Bitwarden does not process the import if there is one item invalid.
|
||||
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
|
||||
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
|
||||
Cipher::validate_notes(&data.ciphers)?;
|
||||
Cipher::validate_cipher_data(&data.ciphers)?;
|
||||
|
||||
// Read and create the folders
|
||||
let existing_folders: Vec<String> =
|
||||
Folder::find_by_user(&headers.user.uuid, &mut conn).await.into_iter().map(|f| f.uuid).collect();
|
||||
let existing_folders: HashSet<Option<String>> =
|
||||
Folder::find_by_user(&headers.user.uuid, &mut conn).await.into_iter().map(|f| Some(f.uuid)).collect();
|
||||
let mut folders: Vec<String> = Vec::with_capacity(data.folders.len());
|
||||
for folder in data.folders.into_iter() {
|
||||
let folder_uuid = if folder.id.is_some() && existing_folders.contains(folder.id.as_ref().unwrap()) {
|
||||
let folder_uuid = if existing_folders.contains(&folder.id) {
|
||||
folder.id.unwrap()
|
||||
} else {
|
||||
let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name);
|
||||
@@ -580,8 +595,8 @@ async fn post_ciphers_import(
|
||||
}
|
||||
|
||||
// Read the relations between folders and ciphers
|
||||
// Ciphers can only be in one folder at the same time
|
||||
let mut relations_map = HashMap::with_capacity(data.folder_relationships.len());
|
||||
|
||||
for relation in data.folder_relationships {
|
||||
relations_map.insert(relation.key, relation.value);
|
||||
}
|
||||
@@ -640,9 +655,8 @@ async fn put_cipher(
|
||||
) -> JsonResult {
|
||||
let data: CipherData = data.into_inner();
|
||||
|
||||
let mut cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(mut cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
// TODO: Check if only the folder ID or favorite status is being changed.
|
||||
@@ -674,19 +688,13 @@ async fn put_cipher_partial(
|
||||
) -> JsonResult {
|
||||
let data: PartialCipherData = data.into_inner();
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if let Some(ref folder_id) = data.folder_id {
|
||||
match Folder::find_by_uuid(folder_id, &mut conn).await {
|
||||
Some(folder) => {
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder is not owned by user")
|
||||
}
|
||||
}
|
||||
None => err!("Folder doesn't exist"),
|
||||
if let Some(ref folder_uuid) = data.folder_id {
|
||||
if Folder::find_by_uuid_and_user(folder_uuid, &headers.user.uuid, &mut conn).await.is_none() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,6 +709,7 @@ async fn put_cipher_partial(
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CollectionsAdminData {
|
||||
#[serde(alias = "CollectionIds")]
|
||||
collection_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -752,9 +761,8 @@ async fn post_collections_update(
|
||||
) -> JsonResult {
|
||||
let data: CollectionsAdminData = data.into_inner();
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -766,7 +774,8 @@ async fn post_collections_update(
|
||||
HashSet::<String>::from_iter(cipher.get_collections(headers.user.uuid.clone(), &mut conn).await);
|
||||
|
||||
for collection in posted_collections.symmetric_difference(¤t_collections) {
|
||||
match Collection::find_by_uuid(collection, &mut conn).await {
|
||||
match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &mut conn).await
|
||||
{
|
||||
None => err!("Invalid collection ID provided"),
|
||||
Some(collection) => {
|
||||
if collection.is_writable_by_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -829,9 +838,8 @@ async fn post_collections_admin(
|
||||
) -> EmptyResult {
|
||||
let data: CollectionsAdminData = data.into_inner();
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -843,7 +851,8 @@ async fn post_collections_admin(
|
||||
HashSet::<String>::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &mut conn).await);
|
||||
|
||||
for collection in posted_collections.symmetric_difference(¤t_collections) {
|
||||
match Collection::find_by_uuid(collection, &mut conn).await {
|
||||
match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &mut conn).await
|
||||
{
|
||||
None => err!("Invalid collection ID provided"),
|
||||
Some(collection) => {
|
||||
if collection.is_writable_by_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -1021,9 +1030,8 @@ async fn share_cipher_by_uuid(
|
||||
/// redirects to the same location as before the v2 API.
|
||||
#[get("/ciphers/<uuid>/attachment/<attachment_id>")]
|
||||
async fn get_attachment(uuid: &str, attachment_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -1062,9 +1070,8 @@ async fn post_attachment_v2(
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let cipher = match Cipher::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -1128,9 +1135,8 @@ async fn save_attachment(
|
||||
err!("Attachment size can't be negative")
|
||||
}
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(cipher_uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &mut conn).await {
|
||||
@@ -1523,21 +1529,15 @@ async fn move_cipher_selected(
|
||||
let data = data.into_inner();
|
||||
let user_uuid = headers.user.uuid;
|
||||
|
||||
if let Some(ref folder_id) = data.folder_id {
|
||||
match Folder::find_by_uuid(folder_id, &mut conn).await {
|
||||
Some(folder) => {
|
||||
if folder.user_uuid != user_uuid {
|
||||
err!("Folder is not owned by user")
|
||||
}
|
||||
}
|
||||
None => err!("Folder doesn't exist"),
|
||||
if let Some(ref folder_uuid) = data.folder_id {
|
||||
if Folder::find_by_uuid_and_user(folder_uuid, &user_uuid, &mut conn).await.is_none() {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||
}
|
||||
}
|
||||
|
||||
for uuid in data.ids {
|
||||
let cipher = match Cipher::find_by_uuid(&uuid, &mut conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(&uuid, &mut conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_accessible_to_user(&user_uuid, &mut conn).await {
|
||||
@@ -1645,9 +1645,8 @@ async fn _delete_cipher_by_uuid(
|
||||
soft_delete: bool,
|
||||
nt: &Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
let mut cipher = match Cipher::find_by_uuid(uuid, conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(mut cipher) = Cipher::find_by_uuid(uuid, conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||
@@ -1717,9 +1716,8 @@ async fn _delete_multiple_ciphers(
|
||||
}
|
||||
|
||||
async fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &mut DbConn, nt: &Notify<'_>) -> JsonResult {
|
||||
let mut cipher = match Cipher::find_by_uuid(uuid, conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(mut cipher) = Cipher::find_by_uuid(uuid, conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||
@@ -1785,18 +1783,16 @@ async fn _delete_cipher_attachment_by_id(
|
||||
conn: &mut DbConn,
|
||||
nt: &Notify<'_>,
|
||||
) -> EmptyResult {
|
||||
let attachment = match Attachment::find_by_id(attachment_id, conn).await {
|
||||
Some(attachment) => attachment,
|
||||
None => err!("Attachment doesn't exist"),
|
||||
let Some(attachment) = Attachment::find_by_id(attachment_id, conn).await else {
|
||||
err!("Attachment doesn't exist")
|
||||
};
|
||||
|
||||
if attachment.cipher_uuid != uuid {
|
||||
err!("Attachment from other cipher")
|
||||
}
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(uuid, conn).await {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist"),
|
||||
let Some(cipher) = Cipher::find_by_uuid(uuid, conn).await else {
|
||||
err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await {
|
||||
|
@@ -137,11 +137,11 @@ async fn post_emergency_access(
|
||||
|
||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emergency_access) => emergency_access,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
@@ -284,24 +284,22 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
||||
async fn resend_invite(emer_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||
err!("The grantee user is already accepted or confirmed to the organization");
|
||||
}
|
||||
|
||||
let email = match emergency_access.email.clone() {
|
||||
Some(email) => email,
|
||||
None => err!("Email not valid."),
|
||||
let Some(email) = emergency_access.email.clone() else {
|
||||
err!("Email not valid.")
|
||||
};
|
||||
|
||||
let grantee_user = match User::find_by_mail(&email, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
let Some(grantee_user) = User::find_by_mail(&email, &mut conn).await else {
|
||||
err!("Grantee user not found.")
|
||||
};
|
||||
|
||||
let grantor_user = headers.user;
|
||||
@@ -356,16 +354,15 @@ async fn accept_invite(emer_id: &str, data: Json<AcceptData>, headers: Headers,
|
||||
|
||||
// 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.
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_email(emer_id, &headers.user.email, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_email(emer_id, &headers.user.email, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
// get grantor user to send Accepted email
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
if emer_id == claims.emer_id
|
||||
@@ -403,11 +400,11 @@ async fn confirm_emergency_access(
|
||||
let data: ConfirmData = data.into_inner();
|
||||
let key = data.key;
|
||||
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &confirming_user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &confirming_user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Accepted as i32
|
||||
|| emergency_access.grantor_uuid != confirming_user.uuid
|
||||
@@ -415,15 +412,13 @@ async fn confirm_emergency_access(
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&confirming_user.uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&confirming_user.uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
||||
err!("Grantee user not found.")
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
||||
@@ -450,19 +445,18 @@ async fn initiate_emergency_access(emer_id: &str, headers: Headers, mut conn: Db
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let initiating_user = headers.user;
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &initiating_user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &initiating_user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -488,25 +482,23 @@ async fn initiate_emergency_access(emer_id: &str, headers: Headers, mut conn: Db
|
||||
async fn approve_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&headers.user.uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
||||
err!("Grantee user not found.")
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32;
|
||||
@@ -525,11 +517,11 @@ async fn approve_emergency_access(emer_id: &str, headers: Headers, mut conn: DbC
|
||||
async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let mut emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(mut emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32
|
||||
&& emergency_access.status != EmergencyAccessStatus::RecoveryApproved as i32
|
||||
@@ -538,9 +530,8 @@ async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbCo
|
||||
}
|
||||
|
||||
if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() {
|
||||
let grantee_user = match User::find_by_uuid(grantee_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantee user not found."),
|
||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &mut conn).await else {
|
||||
err!("Grantee user not found.")
|
||||
};
|
||||
|
||||
emergency_access.status = EmergencyAccessStatus::Confirmed as i32;
|
||||
@@ -563,11 +554,11 @@ async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbCo
|
||||
async fn view_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &headers.user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) {
|
||||
err!("Emergency access not valid.")
|
||||
@@ -602,19 +593,18 @@ async fn takeover_emergency_access(emer_id: &str, headers: Headers, mut conn: Db
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
@@ -650,19 +640,18 @@ async fn password_emergency_access(
|
||||
//let key = &data.Key;
|
||||
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let mut grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(mut grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
// change grantor_user password
|
||||
@@ -686,19 +675,18 @@ async fn password_emergency_access(
|
||||
#[get("/emergency-access/<emer_id>/policies")]
|
||||
async fn policies_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let requesting_user = headers.user;
|
||||
let emergency_access =
|
||||
match EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await {
|
||||
Some(emer) => emer,
|
||||
None => err!("Emergency access not valid."),
|
||||
};
|
||||
let Some(emergency_access) =
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &requesting_user.uuid, &mut conn).await
|
||||
else {
|
||||
err!("Emergency access not valid.")
|
||||
};
|
||||
|
||||
if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) {
|
||||
err!("Emergency access not valid.")
|
||||
}
|
||||
|
||||
let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Grantor user not found."),
|
||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &mut conn).await else {
|
||||
err!("Grantor user not found.")
|
||||
};
|
||||
|
||||
let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &mut conn);
|
||||
|
@@ -25,16 +25,10 @@ async fn get_folders(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/folders/<uuid>")]
|
||||
async fn get_folder(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let folder = match Folder::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder"),
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
match Folder::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await {
|
||||
Some(folder) => Ok(Json(folder.to_json())),
|
||||
_ => err!("Invalid folder", "Folder does not exist or belongs to another user"),
|
||||
}
|
||||
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -71,15 +65,10 @@ async fn put_folder(
|
||||
) -> JsonResult {
|
||||
let data: FolderData = data.into_inner();
|
||||
|
||||
let mut folder = match Folder::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder"),
|
||||
let Some(mut folder) = Folder::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
}
|
||||
|
||||
folder.name = data.name;
|
||||
|
||||
folder.save(&mut conn).await?;
|
||||
@@ -95,15 +84,10 @@ async fn delete_folder_post(uuid: &str, headers: Headers, conn: DbConn, nt: Noti
|
||||
|
||||
#[delete("/folders/<uuid>")]
|
||||
async fn delete_folder(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let folder = match Folder::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder"),
|
||||
let Some(folder) = Folder::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Invalid folder", "Folder does not exist or belongs to another user")
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
}
|
||||
|
||||
// Delete the actual folder entry
|
||||
folder.delete(&mut conn).await?;
|
||||
|
||||
|
@@ -12,6 +12,7 @@ pub use accounts::purge_auth_requests;
|
||||
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
|
||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||
use reqwest::Method;
|
||||
pub use sends::purge_sends;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
@@ -53,7 +54,8 @@ use crate::{
|
||||
auth::Headers,
|
||||
db::DbConn,
|
||||
error::Error,
|
||||
util::{get_reqwest_client, parse_experimental_client_feature_flags},
|
||||
http_client::make_http_request,
|
||||
util::parse_experimental_client_feature_flags,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -133,15 +135,14 @@ async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbC
|
||||
}
|
||||
|
||||
#[get("/hibp/breach?<username>")]
|
||||
async fn hibp_breach(username: &str) -> JsonResult {
|
||||
let url = format!(
|
||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||
);
|
||||
|
||||
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
|
||||
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
|
||||
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
|
||||
let hibp_client = get_reqwest_client();
|
||||
let url = format!(
|
||||
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
|
||||
);
|
||||
|
||||
let res = hibp_client.get(&url).header("hibp-api-key", api_key).send().await?;
|
||||
let res = make_http_request(Method::GET, &url)?.header("hibp-api-key", api_key).send().await?;
|
||||
|
||||
// If we get a 404, return a 404, it means no breached accounts
|
||||
if res.status() == 404 {
|
||||
@@ -190,6 +191,8 @@ fn config() -> Json<Value> {
|
||||
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
|
||||
// Force the new key rotation feature
|
||||
feature_states.insert("key-rotation-improvements".to_string(), true);
|
||||
feature_states.insert("flexible-collections-v-1".to_string(), false);
|
||||
|
||||
Json(json!({
|
||||
// Note: The clients use this version to handle backwards compatibility concerns
|
||||
// This means they expect a version that closely matches the Bitwarden server version
|
||||
@@ -200,8 +203,10 @@ fn config() -> Json<Value> {
|
||||
"gitHash": option_env!("GIT_REV"),
|
||||
"server": {
|
||||
"name": "Vaultwarden",
|
||||
"url": "https://github.com/dani-garcia/vaultwarden",
|
||||
"version": crate::VERSION
|
||||
"url": "https://github.com/dani-garcia/vaultwarden"
|
||||
},
|
||||
"settings": {
|
||||
"disableUserRegistration": !crate::CONFIG.signups_allowed() && crate::CONFIG.signups_domains_whitelist().is_empty(),
|
||||
},
|
||||
"environment": {
|
||||
"vault": domain,
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
request::{self, FromRequest, Outcome},
|
||||
request::{FromRequest, Outcome},
|
||||
serde::json::Json,
|
||||
Request, Route,
|
||||
};
|
||||
@@ -123,15 +123,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
None => err!("Error looking up organization"),
|
||||
};
|
||||
|
||||
mail::send_invite(
|
||||
&user_data.email,
|
||||
&user.uuid,
|
||||
Some(org_id.clone()),
|
||||
Some(new_org_user.uuid),
|
||||
&org_name,
|
||||
Some(org_email),
|
||||
)
|
||||
.await?;
|
||||
mail::send_invite(&user, Some(org_id.clone()), Some(new_org_user.uuid), &org_name, Some(org_email))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +192,7 @@ pub struct PublicToken(String);
|
||||
impl<'r> FromRequest<'r> for PublicToken {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
// Get access_token
|
||||
let access_token: &str = match headers.get_one("Authorization") {
|
||||
@@ -210,9 +203,8 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
None => err_handler!("No access token provided"),
|
||||
};
|
||||
// Check JWT token is valid and get device and user from it
|
||||
let claims = match auth::decode_api_org(access_token) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => err_handler!("Invalid claim"),
|
||||
let Ok(claims) = auth::decode_api_org(access_token) else {
|
||||
err_handler!("Invalid claim")
|
||||
};
|
||||
// Check if time is between claims.nbf and claims.exp
|
||||
let time_now = Utc::now().timestamp();
|
||||
@@ -234,13 +226,11 @@ impl<'r> FromRequest<'r> for PublicToken {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
let org_uuid = match claims.client_id.strip_prefix("organization.") {
|
||||
Some(uuid) => uuid,
|
||||
None => err_handler!("Malformed client_id"),
|
||||
let Some(org_uuid) = claims.client_id.strip_prefix("organization.") else {
|
||||
err_handler!("Malformed client_id")
|
||||
};
|
||||
let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, &conn).await {
|
||||
Some(org_api_key) => org_api_key,
|
||||
None => err_handler!("Invalid client_id"),
|
||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(org_uuid, &conn).await else {
|
||||
err_handler!("Invalid client_id")
|
||||
};
|
||||
if org_api_key.org_uuid != claims.client_sub {
|
||||
err_handler!("Token not issued for this org");
|
||||
|
@@ -159,16 +159,10 @@ async fn get_sends(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[get("/sends/<uuid>")]
|
||||
async fn get_send(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let send = match Send::find_by_uuid(uuid, &mut conn).await {
|
||||
Some(send) => send,
|
||||
None => err!("Send not found"),
|
||||
};
|
||||
|
||||
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
|
||||
err!("Send is not owned by user")
|
||||
match Send::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await {
|
||||
Some(send) => Ok(Json(send.to_json())),
|
||||
None => err!("Send not found", "Invalid uuid or does not belong to user"),
|
||||
}
|
||||
|
||||
Ok(Json(send.to_json()))
|
||||
}
|
||||
|
||||
#[post("/sends", data = "<data>")]
|
||||
@@ -349,7 +343,15 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbC
|
||||
})))
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L243
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct SendFileData {
|
||||
id: String,
|
||||
size: u64,
|
||||
fileName: String,
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/66f95d1c443490b653e5a15d32977e2f5a3f9e32/src/Api/Tools/Controllers/SendsController.cs#L250
|
||||
#[post("/sends/<send_uuid>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
||||
async fn post_send_file_v2_data(
|
||||
send_uuid: &str,
|
||||
@@ -363,19 +365,51 @@ async fn post_send_file_v2_data(
|
||||
|
||||
let mut data = data.into_inner();
|
||||
|
||||
let Some(send) = Send::find_by_uuid(send_uuid, &mut conn).await else {
|
||||
err!("Send not found. Unable to save the file.")
|
||||
let Some(send) = Send::find_by_uuid_and_user(send_uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found. Unable to save the file.", "Invalid uuid or does not belong to user.")
|
||||
};
|
||||
|
||||
let Some(send_user_id) = &send.user_uuid else {
|
||||
err!("Sends are only supported for users at the moment")
|
||||
if send.atype != SendType::File as i32 {
|
||||
err!("Send is not a file type send.");
|
||||
}
|
||||
|
||||
let Ok(send_data) = serde_json::from_str::<SendFileData>(&send.data) else {
|
||||
err!("Unable to decode send data as json.")
|
||||
};
|
||||
if send_user_id != &headers.user.uuid {
|
||||
err!("Send doesn't belong to user");
|
||||
|
||||
match data.data.raw_name() {
|
||||
Some(raw_file_name) if raw_file_name.dangerous_unsafe_unsanitized_raw() == send_data.fileName => (),
|
||||
Some(raw_file_name) => err!(
|
||||
"Send file name does not match.",
|
||||
format!(
|
||||
"Expected file name '{}' got '{}'",
|
||||
send_data.fileName,
|
||||
raw_file_name.dangerous_unsafe_unsanitized_raw()
|
||||
)
|
||||
),
|
||||
_ => err!("Send file name does not match or is not provided."),
|
||||
}
|
||||
|
||||
if file_id != send_data.id {
|
||||
err!("Send file does not match send data.", format!("Expected id {} got {file_id}", send_data.id));
|
||||
}
|
||||
|
||||
let Some(size) = data.data.len().to_u64() else {
|
||||
err!("Send file size overflow.");
|
||||
};
|
||||
|
||||
if size != send_data.size {
|
||||
err!("Send file size does not match.", format!("Expected a file size of {} got {size}", send_data.size));
|
||||
}
|
||||
|
||||
let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(send_uuid);
|
||||
let file_path = folder_path.join(file_id);
|
||||
|
||||
// Check if the file already exists, if that is the case do not overwrite it
|
||||
if tokio::fs::metadata(&file_path).await.is_ok() {
|
||||
err!("Send file has already been uploaded.", format!("File {file_path:?} already exists"))
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(&folder_path).await?;
|
||||
|
||||
if let Err(_err) = data.data.persist_to(&file_path).await {
|
||||
@@ -408,9 +442,8 @@ async fn post_access(
|
||||
ip: ClientIp,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let mut send = match Send::find_by_access_id(access_id, &mut conn).await {
|
||||
Some(s) => s,
|
||||
None => err_code!(SEND_INACCESSIBLE_MSG, 404),
|
||||
let Some(mut send) = Send::find_by_access_id(access_id, &mut conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
@@ -469,9 +502,8 @@ async fn post_access_file(
|
||||
mut conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
let mut send = match Send::find_by_uuid(send_id, &mut conn).await {
|
||||
Some(s) => s,
|
||||
None => err_code!(SEND_INACCESSIBLE_MSG, 404),
|
||||
let Some(mut send) = Send::find_by_uuid(send_id, &mut conn).await else {
|
||||
err_code!(SEND_INACCESSIBLE_MSG, 404)
|
||||
};
|
||||
|
||||
if let Some(max_access_count) = send.max_access_count {
|
||||
@@ -534,16 +566,15 @@ async fn download_send(send_id: SafeString, file_id: SafeString, t: &str) -> Opt
|
||||
None
|
||||
}
|
||||
|
||||
#[put("/sends/<id>", data = "<data>")]
|
||||
async fn put_send(id: &str, data: Json<SendData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
#[put("/sends/<uuid>", data = "<data>")]
|
||||
async fn put_send(uuid: &str, 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();
|
||||
enforce_disable_hide_email_policy(&data, &headers, &mut conn).await?;
|
||||
|
||||
let mut send = match Send::find_by_uuid(id, &mut conn).await {
|
||||
Some(s) => s,
|
||||
None => err!("Send not found"),
|
||||
let Some(mut send) = Send::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found", "Send uuid is invalid or does not belong to user")
|
||||
};
|
||||
|
||||
update_send_from_data(&mut send, data, &headers, &mut conn, &nt, UpdateType::SyncSendUpdate).await?;
|
||||
@@ -609,17 +640,12 @@ pub async fn update_send_from_data(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[delete("/sends/<id>")]
|
||||
async fn delete_send(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let send = match Send::find_by_uuid(id, &mut conn).await {
|
||||
Some(s) => s,
|
||||
None => err!("Send not found"),
|
||||
#[delete("/sends/<uuid>")]
|
||||
async fn delete_send(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let Some(send) = Send::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
||||
};
|
||||
|
||||
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
|
||||
err!("Send is not owned by user")
|
||||
}
|
||||
|
||||
send.delete(&mut conn).await?;
|
||||
nt.send_send_update(
|
||||
UpdateType::SyncSendDelete,
|
||||
@@ -633,19 +659,14 @@ async fn delete_send(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[put("/sends/<id>/remove-password")]
|
||||
async fn put_remove_password(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
#[put("/sends/<uuid>/remove-password")]
|
||||
async fn put_remove_password(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
enforce_disable_send_policy(&headers, &mut conn).await?;
|
||||
|
||||
let mut send = match Send::find_by_uuid(id, &mut conn).await {
|
||||
Some(s) => s,
|
||||
None => err!("Send not found"),
|
||||
let Some(mut send) = Send::find_by_uuid_and_user(uuid, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found", "Invalid send uuid, or does not belong to user")
|
||||
};
|
||||
|
||||
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
|
||||
err!("Send is not owned by user")
|
||||
}
|
||||
|
||||
send.set_password(None);
|
||||
send.save(&mut conn).await?;
|
||||
nt.send_send_update(
|
||||
|
@@ -117,9 +117,8 @@ pub async fn validate_totp_code(
|
||||
) -> EmptyResult {
|
||||
use totp_lite::{totp_custom, Sha1};
|
||||
|
||||
let decoded_secret = match BASE32.decode(secret.as_bytes()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => err!("Invalid TOTP secret"),
|
||||
let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else {
|
||||
err!("Invalid TOTP secret")
|
||||
};
|
||||
|
||||
let mut twofactor =
|
||||
|
@@ -15,7 +15,7 @@ use crate::{
|
||||
DbConn,
|
||||
},
|
||||
error::MapResult,
|
||||
util::get_reqwest_client,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -210,10 +210,7 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
|
||||
|
||||
let m = Method::from_str(method).unwrap_or_default();
|
||||
|
||||
let client = get_reqwest_client();
|
||||
|
||||
client
|
||||
.request(m, &url)
|
||||
make_http_request(m, &url)?
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
|
||||
.header(header::DATE, date)
|
||||
@@ -235,9 +232,8 @@ async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus {
|
||||
let type_ = TwoFactorType::Duo as i32;
|
||||
|
||||
// If the user doesn't have an entry, disabled
|
||||
let twofactor = match TwoFactor::find_by_user_and_type(uuid, type_, conn).await {
|
||||
Some(t) => t,
|
||||
None => return DuoStatus::Disabled(DuoData::global().is_some()),
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(uuid, type_, conn).await else {
|
||||
return DuoStatus::Disabled(DuoData::global().is_some());
|
||||
};
|
||||
|
||||
// If the user has the required values, we use those
|
||||
@@ -255,7 +251,7 @@ async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus {
|
||||
}
|
||||
|
||||
// let (ik, sk, ak, host) = get_duo_keys();
|
||||
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: &mut DbConn) -> ApiResult<(String, String, String, String)> {
|
||||
let data = match User::find_by_mail(email, conn).await {
|
||||
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
|
||||
_ => DuoData::global(),
|
||||
@@ -284,10 +280,6 @@ fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64
|
||||
}
|
||||
|
||||
pub async fn validate_duo_login(email: &str, response: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
// email is as entered by the user, so it needs to be normalized before
|
||||
// comparison with auth_user below.
|
||||
let email = &email.to_lowercase();
|
||||
|
||||
let split: Vec<&str> = response.split(':').collect();
|
||||
if split.len() != 2 {
|
||||
err!(
|
||||
@@ -340,14 +332,12 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
|
||||
err!("Prefixes don't match")
|
||||
}
|
||||
|
||||
let cookie_vec = match BASE64.decode(u_b64.as_bytes()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => err!("Invalid Duo cookie encoding"),
|
||||
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
|
||||
err!("Invalid Duo cookie encoding")
|
||||
};
|
||||
|
||||
let cookie = match String::from_utf8(cookie_vec) {
|
||||
Ok(c) => c,
|
||||
Err(_) => err!("Invalid Duo cookie encoding"),
|
||||
let Ok(cookie) = String::from_utf8(cookie_vec) else {
|
||||
err!("Invalid Duo cookie encoding")
|
||||
};
|
||||
|
||||
let cookie_split: Vec<&str> = cookie.split('|').collect();
|
||||
|
495
src/api/core/two_factor/duo_oidc.rs
Normal file
495
src/api/core/two_factor/duo_oidc.rs
Normal file
@@ -0,0 +1,495 @@
|
||||
use chrono::Utc;
|
||||
use data_encoding::HEXLOWER;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use reqwest::{header, StatusCode};
|
||||
use ring::digest::{digest, Digest, SHA512_256};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactorDuoContext},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
error::Error,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
// The location on this service that Duo should redirect users to. For us, this is a bridge
|
||||
// built in to the Bitwarden clients.
|
||||
// See: https://github.com/bitwarden/clients/blob/main/apps/web/src/connectors/duo-redirect.ts
|
||||
const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html";
|
||||
|
||||
// Number of seconds that a JWT we generate for Duo should be valid for.
|
||||
const JWT_VALIDITY_SECS: i64 = 300;
|
||||
|
||||
// Number of seconds that a Duo context stored in the database should be valid for.
|
||||
const CTX_VALIDITY_SECS: i64 = 300;
|
||||
|
||||
// Expected algorithm used by Duo to sign JWTs.
|
||||
const DUO_RESP_SIGNATURE_ALG: Algorithm = Algorithm::HS512;
|
||||
|
||||
// Signature algorithm we're using to sign JWTs for Duo. Must be either HS512 or HS256.
|
||||
const JWT_SIGNATURE_ALG: Algorithm = Algorithm::HS512;
|
||||
|
||||
// Size of random strings for state and nonce. Must be at least 16 characters and at most 1024 characters.
|
||||
// If increasing this above 64, also increase the size of the twofactor_duo_ctx.state and
|
||||
// twofactor_duo_ctx.nonce database columns for postgres and mariadb.
|
||||
const STATE_LENGTH: usize = 64;
|
||||
|
||||
// client_assertion payload for health checks and obtaining MFA results.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ClientAssertion {
|
||||
pub iss: String,
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub exp: i64,
|
||||
pub jti: String,
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
// authorization request payload sent with clients to Duo for MFA
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AuthorizationRequest {
|
||||
pub response_type: String,
|
||||
pub scope: String,
|
||||
pub exp: i64,
|
||||
pub client_id: String,
|
||||
pub redirect_uri: String,
|
||||
pub state: String,
|
||||
pub duo_uname: String,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
// Duo service health check responses
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum HealthCheckResponse {
|
||||
HealthOK {
|
||||
stat: String,
|
||||
},
|
||||
HealthFail {
|
||||
message: String,
|
||||
message_detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
// Outer structure of response when exchanging authz code for MFA results
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct IdTokenResponse {
|
||||
id_token: String, // IdTokenClaims
|
||||
access_token: String,
|
||||
expires_in: i64,
|
||||
token_type: String,
|
||||
}
|
||||
|
||||
// Inner structure of IdTokenResponse.id_token
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct IdTokenClaims {
|
||||
preferred_username: String,
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
// Duo OIDC Authorization Client
|
||||
// See https://duo.com/docs/oauthapi
|
||||
struct DuoClient {
|
||||
client_id: String, // Duo Client ID (DuoData.ik)
|
||||
client_secret: String, // Duo Client Secret (DuoData.sk)
|
||||
api_host: String, // Duo API hostname (DuoData.host)
|
||||
redirect_uri: String, // URL in this application clients should call for MFA verification
|
||||
}
|
||||
|
||||
impl DuoClient {
|
||||
// Construct a new DuoClient
|
||||
fn new(client_id: String, client_secret: String, api_host: String, redirect_uri: String) -> DuoClient {
|
||||
DuoClient {
|
||||
client_id,
|
||||
client_secret,
|
||||
api_host,
|
||||
redirect_uri,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a client assertion for health checks and authorization code exchange.
|
||||
fn new_client_assertion(&self, url: &str) -> ClientAssertion {
|
||||
let now = Utc::now().timestamp();
|
||||
let jwt_id = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||
|
||||
ClientAssertion {
|
||||
iss: self.client_id.clone(),
|
||||
sub: self.client_id.clone(),
|
||||
aud: url.to_string(),
|
||||
exp: now + JWT_VALIDITY_SECS,
|
||||
jti: jwt_id,
|
||||
iat: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Given a serde-serializable struct, attempt to encode it as a JWT
|
||||
fn encode_duo_jwt<T: Serialize>(&self, jwt_payload: T) -> Result<String, Error> {
|
||||
match jsonwebtoken::encode(
|
||||
&Header::new(JWT_SIGNATURE_ALG),
|
||||
&jwt_payload,
|
||||
&EncodingKey::from_secret(self.client_secret.as_bytes()),
|
||||
) {
|
||||
Ok(token) => Ok(token),
|
||||
Err(e) => err!(format!("Error encoding Duo JWT: {e:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// "required" health check to verify the integration is configured and Duo's services
|
||||
// are up.
|
||||
// https://duo.com/docs/oauthapi#health-check
|
||||
async fn health_check(&self) -> Result<(), Error> {
|
||||
let health_check_url: String = format!("https://{}/oauth/v1/health_check", self.api_host);
|
||||
|
||||
let jwt_payload = self.new_client_assertion(&health_check_url);
|
||||
|
||||
let token = match self.encode_duo_jwt(jwt_payload) {
|
||||
Ok(token) => token,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let mut post_body = HashMap::new();
|
||||
post_body.insert("client_assertion", token);
|
||||
post_body.insert("client_id", self.client_id.clone());
|
||||
|
||||
let res = match make_http_request(reqwest::Method::POST, &health_check_url)?
|
||||
.header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)")
|
||||
.form(&post_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => err!(format!("Error requesting Duo health check: {e:?}")),
|
||||
};
|
||||
|
||||
let response: HealthCheckResponse = match res.json::<HealthCheckResponse>().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => err!(format!("Duo health check response decode error: {e:?}")),
|
||||
};
|
||||
|
||||
let health_stat: String = match response {
|
||||
HealthCheckResponse::HealthOK {
|
||||
stat,
|
||||
} => stat,
|
||||
HealthCheckResponse::HealthFail {
|
||||
message,
|
||||
message_detail,
|
||||
} => err!(format!("Duo health check FAIL response, msg: {}, detail: {}", message, message_detail)),
|
||||
};
|
||||
|
||||
if health_stat != "OK" {
|
||||
err!(format!("Duo health check failed, got OK-like body with stat {health_stat}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Constructs the URL for the authorization request endpoint on Duo's service.
|
||||
// Clients are sent here to continue authentication.
|
||||
// https://duo.com/docs/oauthapi#authorization-request
|
||||
fn make_authz_req_url(&self, duo_username: &str, state: String, nonce: String) -> Result<String, Error> {
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
let jwt_payload = AuthorizationRequest {
|
||||
response_type: String::from("code"),
|
||||
scope: String::from("openid"),
|
||||
exp: now + JWT_VALIDITY_SECS,
|
||||
client_id: self.client_id.clone(),
|
||||
redirect_uri: self.redirect_uri.clone(),
|
||||
state,
|
||||
duo_uname: String::from(duo_username),
|
||||
iss: self.client_id.clone(),
|
||||
aud: format!("https://{}", self.api_host),
|
||||
nonce,
|
||||
};
|
||||
|
||||
let token = self.encode_duo_jwt(jwt_payload)?;
|
||||
|
||||
let authz_endpoint = format!("https://{}/oauth/v1/authorize", self.api_host);
|
||||
let mut auth_url = match Url::parse(authz_endpoint.as_str()) {
|
||||
Ok(url) => url,
|
||||
Err(e) => err!(format!("Error parsing Duo authorization URL: {e:?}")),
|
||||
};
|
||||
|
||||
{
|
||||
let mut query_params = auth_url.query_pairs_mut();
|
||||
query_params.append_pair("response_type", "code");
|
||||
query_params.append_pair("client_id", self.client_id.as_str());
|
||||
query_params.append_pair("request", token.as_str());
|
||||
}
|
||||
|
||||
let final_auth_url = auth_url.to_string();
|
||||
Ok(final_auth_url)
|
||||
}
|
||||
|
||||
// Exchange the authorization code obtained from an access token provided by the user
|
||||
// for the result of the MFA and validate.
|
||||
// See: https://duo.com/docs/oauthapi#access-token (under Response Format)
|
||||
async fn exchange_authz_code_for_result(
|
||||
&self,
|
||||
duo_code: &str,
|
||||
duo_username: &str,
|
||||
nonce: &str,
|
||||
) -> Result<(), Error> {
|
||||
if duo_code.is_empty() {
|
||||
err!("Empty Duo authorization code")
|
||||
}
|
||||
|
||||
let token_url = format!("https://{}/oauth/v1/token", self.api_host);
|
||||
|
||||
let jwt_payload = self.new_client_assertion(&token_url);
|
||||
|
||||
let token = match self.encode_duo_jwt(jwt_payload) {
|
||||
Ok(token) => token,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let mut post_body = HashMap::new();
|
||||
post_body.insert("grant_type", String::from("authorization_code"));
|
||||
post_body.insert("code", String::from(duo_code));
|
||||
|
||||
// Must be the same URL that was supplied in the authorization request for the supplied duo_code
|
||||
post_body.insert("redirect_uri", self.redirect_uri.clone());
|
||||
|
||||
post_body
|
||||
.insert("client_assertion_type", String::from("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"));
|
||||
post_body.insert("client_assertion", token);
|
||||
|
||||
let res = match make_http_request(reqwest::Method::POST, &token_url)?
|
||||
.header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)")
|
||||
.form(&post_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => err!(format!("Error exchanging Duo code: {e:?}")),
|
||||
};
|
||||
|
||||
let status_code = res.status();
|
||||
if status_code != StatusCode::OK {
|
||||
err!(format!("Failure response from Duo: {}", status_code))
|
||||
}
|
||||
|
||||
let response: IdTokenResponse = match res.json::<IdTokenResponse>().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => err!(format!("Error decoding ID token response: {e:?}")),
|
||||
};
|
||||
|
||||
let mut validation = Validation::new(DUO_RESP_SIGNATURE_ALG);
|
||||
validation.set_required_spec_claims(&["exp", "aud", "iss"]);
|
||||
validation.set_audience(&[&self.client_id]);
|
||||
validation.set_issuer(&[token_url.as_str()]);
|
||||
|
||||
let token_data = match jsonwebtoken::decode::<IdTokenClaims>(
|
||||
&response.id_token,
|
||||
&DecodingKey::from_secret(self.client_secret.as_bytes()),
|
||||
&validation,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(e) => err!(format!("Failed to decode Duo token {e:?}")),
|
||||
};
|
||||
|
||||
let matching_nonces = crypto::ct_eq(nonce, &token_data.claims.nonce);
|
||||
let matching_usernames = crypto::ct_eq(duo_username, &token_data.claims.preferred_username);
|
||||
|
||||
if !(matching_nonces && matching_usernames) {
|
||||
err!("Error validating Duo authorization, nonce or username mismatch.")
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DuoAuthContext {
|
||||
pub state: String,
|
||||
pub user_email: String,
|
||||
pub nonce: String,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
// Given a state string, retrieve the associated Duo auth context and
|
||||
// delete the retrieved state from the database.
|
||||
async fn extract_context(state: &str, conn: &mut DbConn) -> Option<DuoAuthContext> {
|
||||
let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await {
|
||||
Some(c) => c,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
if ctx.exp < Utc::now().timestamp() {
|
||||
ctx.delete(conn).await.ok();
|
||||
return None;
|
||||
}
|
||||
|
||||
// Copy the context data, so that we can delete the context from
|
||||
// the database before returning.
|
||||
let ret_ctx = DuoAuthContext {
|
||||
state: ctx.state.clone(),
|
||||
user_email: ctx.user_email.clone(),
|
||||
nonce: ctx.nonce.clone(),
|
||||
exp: ctx.exp,
|
||||
};
|
||||
|
||||
ctx.delete(conn).await.ok();
|
||||
Some(ret_ctx)
|
||||
}
|
||||
|
||||
// Task to clean up expired Duo authentication contexts that may have accumulated in the database.
|
||||
pub async fn purge_duo_contexts(pool: DbPool) {
|
||||
debug!("Purging Duo authentication contexts");
|
||||
if let Ok(mut conn) = pool.get().await {
|
||||
TwoFactorDuoContext::purge_expired_duo_contexts(&mut conn).await;
|
||||
} else {
|
||||
error!("Failed to get DB connection while purging expired Duo authentications")
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the url that Duo should redirect users to.
|
||||
fn make_callback_url(client_name: &str) -> Result<String, Error> {
|
||||
// Get the location of this application as defined in the config.
|
||||
let base = match Url::parse(&format!("{}/", CONFIG.domain())) {
|
||||
Ok(url) => url,
|
||||
Err(e) => err!(format!("Error parsing configured domain URL (check your domain configuration): {e:?}")),
|
||||
};
|
||||
|
||||
// Add the client redirect bridge location
|
||||
let mut callback = match base.join(DUO_REDIRECT_LOCATION) {
|
||||
Ok(url) => url,
|
||||
Err(e) => err!(format!("Error constructing Duo redirect URL (check your domain configuration): {e:?}")),
|
||||
};
|
||||
|
||||
// Add the 'client' string with the authenticating device type. The callback connector uses this
|
||||
// information to figure out how it should handle certain clients.
|
||||
{
|
||||
let mut query_params = callback.query_pairs_mut();
|
||||
query_params.append_pair("client", client_name);
|
||||
}
|
||||
Ok(callback.to_string())
|
||||
}
|
||||
|
||||
// Pre-redirect first stage of the Duo OIDC authentication flow.
|
||||
// Returns the "AuthUrl" that should be returned to clients for MFA.
|
||||
pub async fn get_duo_auth_url(
|
||||
email: &str,
|
||||
client_id: &str,
|
||||
device_identifier: &String,
|
||||
conn: &mut DbConn,
|
||||
) -> Result<String, Error> {
|
||||
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
let callback_url = match make_callback_url(client_id) {
|
||||
Ok(url) => url,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let client = DuoClient::new(ik, sk, host, callback_url);
|
||||
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Generate random OAuth2 state and OIDC Nonce
|
||||
let state: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||
let nonce: String = crypto::get_random_string_alphanum(STATE_LENGTH);
|
||||
|
||||
// Bind the nonce to the device that's currently authing by hashing the nonce and device id
|
||||
// and sending the result as the OIDC nonce.
|
||||
let d: Digest = digest(&SHA512_256, format!("{nonce}{device_identifier}").as_bytes());
|
||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||
|
||||
match TwoFactorDuoContext::save(state.as_str(), email, nonce.as_str(), CTX_VALIDITY_SECS, conn).await {
|
||||
Ok(()) => client.make_authz_req_url(email, state, hash),
|
||||
Err(e) => err!(format!("Error saving Duo authentication context: {e:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Post-redirect second stage of the Duo OIDC authentication flow.
|
||||
// Exchanges an authorization code for the MFA result with Duo's API and validates the result.
|
||||
pub async fn validate_duo_login(
|
||||
email: &str,
|
||||
two_factor_token: &str,
|
||||
client_id: &str,
|
||||
device_identifier: &str,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
// Result supplied to us by clients in the form "<authz code>|<state>"
|
||||
let split: Vec<&str> = two_factor_token.split('|').collect();
|
||||
if split.len() != 2 {
|
||||
err!(
|
||||
"Invalid response length",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let code = split[0];
|
||||
let state = split[1];
|
||||
|
||||
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
|
||||
|
||||
// Get the context by the state reported by the client. If we don't have one,
|
||||
// it means the context is either missing or expired.
|
||||
let ctx = match extract_context(state, conn).await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Context validation steps
|
||||
let matching_usernames = crypto::ct_eq(email, &ctx.user_email);
|
||||
|
||||
// Probably redundant, but we're double-checking them anyway.
|
||||
let matching_states = crypto::ct_eq(state, &ctx.state);
|
||||
let unexpired_context = ctx.exp > Utc::now().timestamp();
|
||||
|
||||
if !(matching_usernames && matching_states && unexpired_context) {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let callback_url = match make_callback_url(client_id) {
|
||||
Ok(url) => url,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let client = DuoClient::new(ik, sk, host, callback_url);
|
||||
|
||||
match client.health_check().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let d: Digest = digest(&SHA512_256, format!("{}{}", ctx.nonce, device_identifier).as_bytes());
|
||||
let hash: String = HEXLOWER.encode(d.as_ref());
|
||||
|
||||
match client.exchange_authz_code_for_result(code, email, hash.as_str()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => {
|
||||
err!(
|
||||
"Error validating duo authentication",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@@ -24,7 +24,10 @@ pub fn routes() -> Vec<Route> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SendEmailLoginData {
|
||||
// DeviceIdentifier: String, // Currently not used
|
||||
#[serde(alias = "Email")]
|
||||
email: String,
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
master_password_hash: String,
|
||||
}
|
||||
|
||||
@@ -37,9 +40,8 @@ async fn send_email_login(data: Json<SendEmailLoginData>, mut conn: DbConn) -> E
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let user = match User::find_by_mail(&data.email, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Username or password is incorrect. Try again."),
|
||||
let Some(user) = User::find_by_mail(&data.email, &mut conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
// Check password
|
||||
@@ -171,9 +173,8 @@ async fn email(data: Json<EmailData>, headers: Headers, mut conn: DbConn) -> Jso
|
||||
|
||||
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
|
||||
|
||||
let issued_token = match &email_data.last_token {
|
||||
Some(t) => t,
|
||||
_ => err!("No token available"),
|
||||
let Some(issued_token) = &email_data.last_token else {
|
||||
err!("No token available")
|
||||
};
|
||||
|
||||
if !crypto::ct_eq(issued_token, data.token) {
|
||||
@@ -202,14 +203,13 @@ pub async fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, c
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Email as i32, conn)
|
||||
.await
|
||||
.map_res("Two factor not found")?;
|
||||
let issued_token = match &email_data.last_token {
|
||||
Some(t) => t,
|
||||
_ => err!(
|
||||
let Some(issued_token) = &email_data.last_token else {
|
||||
err!(
|
||||
"No token available",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn2fa
|
||||
}
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
if !crypto::ct_eq(issued_token, token) {
|
||||
@@ -289,7 +289,7 @@ impl EmailTokenData {
|
||||
}
|
||||
|
||||
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> {
|
||||
let res: Result<EmailTokenData, crate::serde_json::Error> = serde_json::from_str(string);
|
||||
let res: Result<EmailTokenData, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode EmailTokenData from string"),
|
||||
|
@@ -19,6 +19,7 @@ use crate::{
|
||||
|
||||
pub mod authenticator;
|
||||
pub mod duo;
|
||||
pub mod duo_oidc;
|
||||
pub mod email;
|
||||
pub mod protected_actions;
|
||||
pub mod webauthn;
|
||||
@@ -84,9 +85,8 @@ async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, mu
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let mut user = match User::find_by_mail(&data.email, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Username or password is incorrect. Try again."),
|
||||
let Some(mut user) = User::find_by_mail(&data.email, &mut conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
};
|
||||
|
||||
// Check password
|
||||
@@ -268,10 +268,24 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||
user.email, login.ip_address
|
||||
);
|
||||
mail::send_incomplete_2fa_login(&user.email, &login.ip_address, &login.login_time, &login.device_name)
|
||||
.await
|
||||
.expect("Error sending incomplete 2FA email");
|
||||
login.delete(&mut conn).await.expect("Error deleting incomplete 2FA record");
|
||||
match mail::send_incomplete_2fa_login(
|
||||
&user.email,
|
||||
&login.ip_address,
|
||||
&login.login_time,
|
||||
&login.device_name,
|
||||
&DeviceType::from_i32(login.device_type).to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
if let Err(e) = login.delete(&mut conn).await {
|
||||
error!("Error deleting incomplete 2FA record: {e:#?}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error sending incomplete 2FA email: {e:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -42,7 +42,7 @@ impl ProtectedActionData {
|
||||
}
|
||||
|
||||
pub fn from_json(string: &str) -> Result<Self, Error> {
|
||||
let res: Result<Self, crate::serde_json::Error> = serde_json::from_str(string);
|
||||
let res: Result<Self, serde_json::Error> = serde_json::from_str(string);
|
||||
match res {
|
||||
Ok(x) => Ok(x),
|
||||
Err(_) => err!("Could not decode ProtectedActionData from string"),
|
||||
|
@@ -309,17 +309,16 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn:
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let mut tf =
|
||||
match TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &mut conn).await {
|
||||
Some(tf) => tf,
|
||||
None => err!("Webauthn data not found!"),
|
||||
};
|
||||
let Some(mut tf) =
|
||||
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &mut conn).await
|
||||
else {
|
||||
err!("Webauthn data not found!")
|
||||
};
|
||||
|
||||
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||
|
||||
let item_pos = match data.iter().position(|r| r.id == id) {
|
||||
Some(p) => p,
|
||||
None => err!("Webauthn entry not found"),
|
||||
let Some(item_pos) = data.iter().position(|r| r.id == id) else {
|
||||
err!("Webauthn entry not found")
|
||||
};
|
||||
|
||||
let removed_item = data.remove(item_pos);
|
||||
|
@@ -49,7 +49,7 @@ fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
|
||||
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
|
||||
}
|
||||
|
||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value {
|
||||
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
|
||||
let mut result = Value::Object(serde_json::Map::new());
|
||||
|
||||
for (i, key) in yubikeys.into_iter().enumerate() {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::IpAddr,
|
||||
sync::{Arc, Mutex},
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
@@ -18,11 +19,12 @@ use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
};
|
||||
|
||||
use html5gum::{Emitter, HtmlString, InfallibleTokenizer, Readable, StringReader, Tokenizer};
|
||||
use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer};
|
||||
|
||||
use crate::{
|
||||
error::Error,
|
||||
util::{get_reqwest_client_builder, Cached, CustomDnsResolver, CustomResolverError},
|
||||
http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError},
|
||||
util::Cached,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -53,7 +55,6 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
||||
.timeout(icon_download_timeout)
|
||||
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
|
||||
.pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds
|
||||
.dns_resolver(CustomDnsResolver::instance())
|
||||
.default_headers(default_headers.clone())
|
||||
.build()
|
||||
.expect("Failed to build client")
|
||||
@@ -69,7 +70,8 @@ fn icon_external(domain: &str) -> Option<Redirect> {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_domain_blacklisted(domain) {
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {}", domain);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -99,6 +101,15 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
|
||||
);
|
||||
}
|
||||
|
||||
if should_block_address(domain) {
|
||||
warn!("Blocked address: {}", domain);
|
||||
return Cached::ttl(
|
||||
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
|
||||
CONFIG.icon_cache_negttl(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
match get_icon(domain).await {
|
||||
Some((icon, icon_type)) => {
|
||||
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
|
||||
@@ -144,30 +155,6 @@ fn is_valid_domain(domain: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_domain_blacklisted(domain: &str) -> bool {
|
||||
let Some(config_blacklist) = CONFIG.icon_blacklist_regex() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Compiled domain blacklist
|
||||
static COMPILED_BLACKLIST: Mutex<Option<(String, Regex)>> = Mutex::new(None);
|
||||
let mut guard = COMPILED_BLACKLIST.lock().unwrap();
|
||||
|
||||
// If the stored regex is up to date, use it
|
||||
if let Some((value, regex)) = &*guard {
|
||||
if value == &config_blacklist {
|
||||
return regex.is_match(domain);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a regex stored, or it's not up to date, recreate it
|
||||
let regex = Regex::new(&config_blacklist).unwrap();
|
||||
let is_match = regex.is_match(domain);
|
||||
*guard = Some((config_blacklist, regex));
|
||||
|
||||
is_match
|
||||
}
|
||||
|
||||
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain);
|
||||
|
||||
@@ -195,9 +182,9 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
|
||||
Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// If this error comes from the custom resolver, this means this is a blacklisted domain
|
||||
// If this error comes from the custom resolver, this means this is a blocked domain
|
||||
// or non global IP, don't save the miss file in this case to avoid leaking it
|
||||
if let Some(error) = CustomResolverError::downcast_ref(&e) {
|
||||
if let Some(error) = CustomHttpClientError::downcast_ref(&e) {
|
||||
warn!("{error}");
|
||||
return None;
|
||||
}
|
||||
@@ -274,11 +261,7 @@ impl Icon {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_favicons_node(
|
||||
dom: InfallibleTokenizer<StringReader<'_>, FaviconEmitter>,
|
||||
icons: &mut Vec<Icon>,
|
||||
url: &url::Url,
|
||||
) {
|
||||
fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &mut Vec<Icon>, url: &url::Url) {
|
||||
const TAG_LINK: &[u8] = b"link";
|
||||
const TAG_BASE: &[u8] = b"base";
|
||||
const TAG_HEAD: &[u8] = b"head";
|
||||
@@ -287,7 +270,7 @@ fn get_favicons_node(
|
||||
|
||||
let mut base_url = url.clone();
|
||||
let mut icon_tags: Vec<Tag> = Vec::new();
|
||||
for token in dom {
|
||||
for Ok(token) in dom {
|
||||
let tag_name: &[u8] = &token.tag.name;
|
||||
match tag_name {
|
||||
TAG_LINK => {
|
||||
@@ -353,7 +336,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
|
||||
// First check the domain as given during the request for HTTPS.
|
||||
let resp = match get_page(&ssldomain).await {
|
||||
Err(e) if CustomResolverError::downcast_ref(&e).is_none() => {
|
||||
Err(e) if CustomHttpClientError::downcast_ref(&e).is_none() => {
|
||||
// If we get an error that is not caused by the blacklist, we retry with HTTP
|
||||
match get_page(&httpdomain).await {
|
||||
mut sub_resp @ Err(_) => {
|
||||
@@ -414,7 +397,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
|
||||
// 384KB should be more than enough for the HTML, though as we only really need the HTML header.
|
||||
let limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.to_vec();
|
||||
|
||||
let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()).infallible();
|
||||
let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default());
|
||||
get_favicons_node(dom, &mut iconlist, &url);
|
||||
} else {
|
||||
// Add the default favicon.ico to the list with just the given domain
|
||||
@@ -460,6 +443,9 @@ 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", "");
|
||||
/// ```
|
||||
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
||||
static PRIORITY_MAP: Lazy<HashMap<&'static str, u8>> =
|
||||
Lazy::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect());
|
||||
|
||||
// Check if there is a dimension set
|
||||
let (width, height) = parse_sizes(sizes);
|
||||
|
||||
@@ -484,13 +470,9 @@ fn get_icon_priority(href: &str, sizes: &str) -> u8 {
|
||||
200
|
||||
}
|
||||
} else {
|
||||
// Change priority by file extension
|
||||
if href.ends_with(".png") {
|
||||
10
|
||||
} else if href.ends_with(".jpg") || href.ends_with(".jpeg") {
|
||||
20
|
||||
} else {
|
||||
30
|
||||
match href.rsplit_once('.') {
|
||||
Some((_, extension)) => PRIORITY_MAP.get(&*extension.to_ascii_lowercase()).copied().unwrap_or(30),
|
||||
None => 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,7 +619,7 @@ use cookie_store::CookieStore;
|
||||
pub struct Jar(std::sync::RwLock<CookieStore>);
|
||||
|
||||
impl reqwest::cookie::CookieStore for Jar {
|
||||
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &header::HeaderValue>, url: &url::Url) {
|
||||
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &url::Url) {
|
||||
use cookie::{Cookie as RawCookie, ParseError as RawCookieParseError};
|
||||
use time::Duration;
|
||||
|
||||
@@ -656,7 +638,7 @@ impl reqwest::cookie::CookieStore for Jar {
|
||||
cookie_store.store_response_cookies(cookies, url);
|
||||
}
|
||||
|
||||
fn cookies(&self, url: &url::Url) -> Option<header::HeaderValue> {
|
||||
fn cookies(&self, url: &url::Url) -> Option<HeaderValue> {
|
||||
let cookie_store = self.0.read().unwrap();
|
||||
let s = cookie_store
|
||||
.get_request_values(url)
|
||||
@@ -668,7 +650,7 @@ impl reqwest::cookie::CookieStore for Jar {
|
||||
return None;
|
||||
}
|
||||
|
||||
header::HeaderValue::from_maybe_shared(Bytes::from(s)).ok()
|
||||
HeaderValue::from_maybe_shared(Bytes::from(s)).ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,7 +658,7 @@ impl reqwest::cookie::CookieStore for Jar {
|
||||
/// The FaviconEmitter is using an optimized version of the DefaultEmitter.
|
||||
/// This prevents emitting tags like comments, doctype and also strings between the tags.
|
||||
/// But it will also only emit the tags we need and only if they have the correct attributes
|
||||
/// Therefor parsing the HTML content is faster.
|
||||
/// Therefore parsing the HTML content is faster.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Default)]
|
||||
|
@@ -12,7 +12,7 @@ use crate::{
|
||||
core::{
|
||||
accounts::{PreloginData, RegisterData, _prelogin, _register},
|
||||
log_user_event,
|
||||
two_factor::{authenticator, duo, email, enforce_2fa_policy, webauthn, yubikey},
|
||||
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
|
||||
},
|
||||
push::register_push_device,
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
@@ -120,21 +120,25 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
||||
"expires_in": expires_in,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": device.refresh_token,
|
||||
"Key": user.akey,
|
||||
"PrivateKey": user.private_key,
|
||||
|
||||
"Kdf": user.client_kdf_type,
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||
"scope": scope,
|
||||
"unofficialServer": true,
|
||||
});
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MasterPasswordPolicy {
|
||||
min_complexity: u8,
|
||||
min_length: u32,
|
||||
require_lower: bool,
|
||||
require_upper: bool,
|
||||
require_numbers: bool,
|
||||
require_special: bool,
|
||||
enforce_on_login: bool,
|
||||
}
|
||||
|
||||
async fn _password_login(
|
||||
data: ConnectData,
|
||||
user_uuid: &mut Option<String>,
|
||||
@@ -153,28 +157,30 @@ async fn _password_login(
|
||||
|
||||
// Get the user
|
||||
let username = data.username.as_ref().unwrap().trim();
|
||||
let mut user = match User::find_by_mail(username, conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username)),
|
||||
let Some(mut user) = User::find_by_mail(username, conn).await else {
|
||||
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username))
|
||||
};
|
||||
|
||||
// Set the user_uuid here to be passed back used for event logging.
|
||||
*user_uuid = Some(user.uuid.clone());
|
||||
|
||||
// Check password
|
||||
let password = data.password.as_ref().unwrap();
|
||||
if let Some(auth_request_uuid) = data.auth_request.clone() {
|
||||
if let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_uuid.as_str(), conn).await {
|
||||
if !auth_request.check_access_code(password) {
|
||||
err!(
|
||||
"Username or access code is incorrect. Try again",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
)
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
} else {
|
||||
)
|
||||
}
|
||||
|
||||
let password = data.password.as_ref().unwrap();
|
||||
|
||||
// If we get an auth request, we don't check the user's password, but the access code of the auth request
|
||||
if let Some(ref auth_request_uuid) = data.auth_request {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_uuid.as_str(), &user.uuid, conn).await
|
||||
else {
|
||||
err!(
|
||||
"Auth request not found. Try again.",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
@@ -182,6 +188,24 @@ async fn _password_login(
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
let expiration_time = auth_request.creation_date + chrono::Duration::minutes(5);
|
||||
let request_expired = Utc::now().naive_utc() >= expiration_time;
|
||||
|
||||
if auth_request.user_uuid != user.uuid
|
||||
|| !auth_request.approved.unwrap_or(false)
|
||||
|| request_expired
|
||||
|| ip.ip.to_string() != auth_request.request_ip
|
||||
|| !auth_request.check_access_code(password)
|
||||
{
|
||||
err!(
|
||||
"Username or access code is incorrect. Try again",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if !user.check_valid_password(password) {
|
||||
err!(
|
||||
@@ -193,8 +217,8 @@ async fn _password_login(
|
||||
)
|
||||
}
|
||||
|
||||
// Change the KDF Iterations
|
||||
if user.password_iterations != CONFIG.password_iterations() {
|
||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||
if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(password, None, false, None);
|
||||
|
||||
@@ -203,17 +227,6 @@ async fn _password_login(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
@@ -253,7 +266,7 @@ async fn _password_login(
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
|
||||
error!("Error sending new device email: {:#?}", e);
|
||||
|
||||
if CONFIG.require_device_email() {
|
||||
@@ -282,6 +295,36 @@ async fn _password_login(
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
||||
device.save(conn).await?;
|
||||
|
||||
// Fetch all valid Master Password Policies and merge them into one with all true's and larges numbers as one policy
|
||||
let master_password_policies: Vec<MasterPasswordPolicy> =
|
||||
OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(
|
||||
&user.uuid,
|
||||
OrgPolicyType::MasterPassword,
|
||||
conn,
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|p| serde_json::from_str(&p.data).ok())
|
||||
.collect();
|
||||
|
||||
let master_password_policy = if !master_password_policies.is_empty() {
|
||||
let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| {
|
||||
MasterPasswordPolicy {
|
||||
min_complexity: acc.min_complexity.max(policy.min_complexity),
|
||||
min_length: acc.min_length.max(policy.min_length),
|
||||
require_lower: acc.require_lower || policy.require_lower,
|
||||
require_upper: acc.require_upper || policy.require_upper,
|
||||
require_numbers: acc.require_numbers || policy.require_numbers,
|
||||
require_special: acc.require_special || policy.require_special,
|
||||
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
|
||||
}
|
||||
}));
|
||||
mpp_json["object"] = json!("masterPasswordPolicy");
|
||||
mpp_json
|
||||
} else {
|
||||
json!({"object": "masterPasswordPolicy"})
|
||||
};
|
||||
|
||||
let mut result = json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
@@ -297,12 +340,9 @@ async fn _password_login(
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: Same as above
|
||||
"ForcePasswordReset": false,
|
||||
"MasterPasswordPolicy": {
|
||||
"object": "masterPasswordPolicy",
|
||||
},
|
||||
"MasterPasswordPolicy": master_password_policy,
|
||||
|
||||
"scope": scope,
|
||||
"unofficialServer": true,
|
||||
"UserDecryptionOptions": {
|
||||
"HasMasterPassword": !user.password_hash.is_empty(),
|
||||
"Object": "userDecryptionOptions"
|
||||
@@ -342,13 +382,11 @@ async fn _user_api_key_login(
|
||||
) -> JsonResult {
|
||||
// Get the user via the client_id
|
||||
let client_id = data.client_id.as_ref().unwrap();
|
||||
let client_user_uuid = match client_id.strip_prefix("user.") {
|
||||
Some(uuid) => uuid,
|
||||
None => err!("Malformed client_id", format!("IP: {}.", ip.ip)),
|
||||
let Some(client_user_uuid) = client_id.strip_prefix("user.") else {
|
||||
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
let user = match User::find_by_uuid(client_user_uuid, conn).await {
|
||||
Some(user) => user,
|
||||
None => err!("Invalid client_id", format!("IP: {}.", ip.ip)),
|
||||
let Some(user) = User::find_by_uuid(client_user_uuid, conn).await else {
|
||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
|
||||
// Set the user_uuid here to be passed back used for event logging.
|
||||
@@ -381,7 +419,7 @@ async fn _user_api_key_login(
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
let now = Utc::now().naive_utc();
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
|
||||
error!("Error sending new device email: {:#?}", e);
|
||||
|
||||
if CONFIG.require_device_email() {
|
||||
@@ -421,9 +459,8 @@ async fn _user_api_key_login(
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: Same as above
|
||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||
"scope": "api",
|
||||
"unofficialServer": true,
|
||||
});
|
||||
|
||||
Ok(Json(result))
|
||||
@@ -432,13 +469,11 @@ async fn _user_api_key_login(
|
||||
async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Get the org via the client_id
|
||||
let client_id = data.client_id.as_ref().unwrap();
|
||||
let org_uuid = match client_id.strip_prefix("organization.") {
|
||||
Some(uuid) => uuid,
|
||||
None => err!("Malformed client_id", format!("IP: {}.", ip.ip)),
|
||||
let Some(org_uuid) = client_id.strip_prefix("organization.") else {
|
||||
err!("Malformed client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, conn).await {
|
||||
Some(org_api_key) => org_api_key,
|
||||
None => err!("Invalid client_id", format!("IP: {}.", ip.ip)),
|
||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(org_uuid, conn).await else {
|
||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
|
||||
// Check API key.
|
||||
@@ -455,7 +490,6 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"scope": "api.organization",
|
||||
"unofficialServer": true,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -495,14 +529,16 @@ async fn twofactor_auth(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, ip, conn).await?;
|
||||
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
|
||||
|
||||
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect();
|
||||
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
|
||||
|
||||
let twofactor_code = match data.two_factor_token {
|
||||
Some(ref code) => code,
|
||||
None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"),
|
||||
None => {
|
||||
err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided")
|
||||
}
|
||||
};
|
||||
|
||||
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
||||
@@ -519,7 +555,23 @@ async fn twofactor_auth(
|
||||
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
||||
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
||||
Some(TwoFactorType::Duo) => {
|
||||
duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await?
|
||||
match CONFIG.duo_use_iframe() {
|
||||
true => {
|
||||
// Legacy iframe prompt flow
|
||||
duo::validate_duo_login(&user.email, twofactor_code, conn).await?
|
||||
}
|
||||
false => {
|
||||
// OIDC based flow
|
||||
duo_oidc::validate_duo_login(
|
||||
&user.email,
|
||||
twofactor_code,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(TwoFactorType::Email) => {
|
||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await?
|
||||
@@ -532,7 +584,7 @@ async fn twofactor_auth(
|
||||
}
|
||||
_ => {
|
||||
err_json!(
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?,
|
||||
_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?,
|
||||
"2FA Remember token not provided"
|
||||
)
|
||||
}
|
||||
@@ -560,7 +612,12 @@ fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||
tf.map(|t| t.data).map_res("Two factor doesn't exist")
|
||||
}
|
||||
|
||||
async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbConn) -> ApiResult<Value> {
|
||||
async fn _json_err_twofactor(
|
||||
providers: &[i32],
|
||||
user_uuid: &str,
|
||||
data: &ConnectData,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<Value> {
|
||||
let mut result = json!({
|
||||
"error" : "invalid_grant",
|
||||
"error_description" : "Two factor required.",
|
||||
@@ -588,18 +645,35 @@ async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbCo
|
||||
None => err!("User does not exist"),
|
||||
};
|
||||
|
||||
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
||||
match CONFIG.duo_use_iframe() {
|
||||
true => {
|
||||
// Legacy iframe prompt flow
|
||||
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"Host": host,
|
||||
"Signature": signature,
|
||||
})
|
||||
}
|
||||
false => {
|
||||
// OIDC based flow
|
||||
let auth_url = duo_oidc::get_duo_auth_url(
|
||||
&email,
|
||||
data.client_id.as_ref().unwrap(),
|
||||
data.device_identifier.as_ref().unwrap(),
|
||||
conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"Host": host,
|
||||
"Signature": signature,
|
||||
});
|
||||
result["TwoFactorProviders2"][provider.to_string()] = json!({
|
||||
"AuthUrl": auth_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(tf_type @ TwoFactorType::YubiKey) => {
|
||||
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await {
|
||||
Some(tf) => tf,
|
||||
None => err!("No YubiKey devices registered"),
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await else {
|
||||
err!("No YubiKey devices registered")
|
||||
};
|
||||
|
||||
let yubikey_metadata: yubikey::YubikeyMetadata = serde_json::from_str(&twofactor.data)?;
|
||||
@@ -610,9 +684,8 @@ async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbCo
|
||||
}
|
||||
|
||||
Some(tf_type @ TwoFactorType::Email) => {
|
||||
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await {
|
||||
Some(tf) => tf,
|
||||
None => err!("No twofactor email registered"),
|
||||
let Some(twofactor) = TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn).await else {
|
||||
err!("No twofactor email registered")
|
||||
};
|
||||
|
||||
// Send email immediately if email is the only 2FA option
|
||||
|
@@ -20,7 +20,7 @@ pub use crate::api::{
|
||||
core::two_factor::send_incomplete_2fa_notifications,
|
||||
core::{emergency_notification_reminder_job, emergency_request_timeout_job},
|
||||
core::{event_cleanup_job, events_routes as core_events_routes},
|
||||
icons::{is_domain_blacklisted, routes as icons_routes},
|
||||
icons::routes as icons_routes,
|
||||
identity::routes as identity_routes,
|
||||
notifications::routes as notifications_routes,
|
||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||
|
@@ -428,7 +428,7 @@ impl WebSocketUsers {
|
||||
let (user_uuid, collection_uuids, revision_date) = if let Some(collection_uuids) = collection_uuids {
|
||||
(
|
||||
Value::Nil,
|
||||
Value::Array(collection_uuids.into_iter().map(|v| v.into()).collect::<Vec<rmpv::Value>>()),
|
||||
Value::Array(collection_uuids.into_iter().map(|v| v.into()).collect::<Vec<Value>>()),
|
||||
serialize_date(Utc::now().naive_utc()),
|
||||
)
|
||||
} else {
|
||||
|
@@ -1,11 +1,15 @@
|
||||
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
api::{ApiResult, EmptyResult, UpdateType},
|
||||
db::models::{Cipher, Device, Folder, Send, User},
|
||||
util::get_reqwest_client,
|
||||
http_client::make_http_request,
|
||||
util::format_date,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -50,8 +54,7 @@ async fn get_auth_push_token() -> ApiResult<String> {
|
||||
("client_secret", &client_secret),
|
||||
];
|
||||
|
||||
let res = match get_reqwest_client()
|
||||
.post(&format!("{}/connect/token", CONFIG.push_identity_uri()))
|
||||
let res = match make_http_request(Method::POST, &format!("{}/connect/token", CONFIG.push_identity_uri()))?
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
@@ -104,8 +107,7 @@ pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbC
|
||||
let auth_push_token = get_auth_push_token().await?;
|
||||
let auth_header = format!("Bearer {}", &auth_push_token);
|
||||
|
||||
if let Err(e) = get_reqwest_client()
|
||||
.post(CONFIG.push_relay_uri() + "/push/register")
|
||||
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))?
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ACCEPT, "application/json")
|
||||
.header(AUTHORIZATION, auth_header)
|
||||
@@ -132,8 +134,7 @@ pub async fn unregister_push_device(push_uuid: Option<String>) -> EmptyResult {
|
||||
|
||||
let auth_header = format!("Bearer {}", &auth_push_token);
|
||||
|
||||
match get_reqwest_client()
|
||||
.delete(CONFIG.push_relay_uri() + "/push/" + &push_uuid.unwrap())
|
||||
match make_http_request(Method::DELETE, &(CONFIG.push_relay_uri() + "/push/" + &push_uuid.unwrap()))?
|
||||
.header(AUTHORIZATION, auth_header)
|
||||
.send()
|
||||
.await
|
||||
@@ -154,12 +155,9 @@ pub async fn push_cipher_update(
|
||||
if cipher.organization_uuid.is_some() {
|
||||
return;
|
||||
};
|
||||
let user_uuid = match &cipher.user_uuid {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
debug!("Cipher has no uuid");
|
||||
return;
|
||||
}
|
||||
let Some(user_uuid) = &cipher.user_uuid else {
|
||||
debug!("Cipher has no uuid");
|
||||
return;
|
||||
};
|
||||
|
||||
if Device::check_user_has_push_device(user_uuid, conn).await {
|
||||
@@ -170,10 +168,10 @@ pub async fn push_cipher_update(
|
||||
"identifier": acting_device_uuid,
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"id": cipher.uuid,
|
||||
"userId": cipher.user_uuid,
|
||||
"organizationId": (),
|
||||
"revisionDate": cipher.updated_at
|
||||
"Id": cipher.uuid,
|
||||
"UserId": cipher.user_uuid,
|
||||
"OrganizationId": (),
|
||||
"RevisionDate": format_date(&cipher.updated_at)
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
@@ -190,8 +188,8 @@ pub fn push_logout(user: &User, acting_device_uuid: Option<String>) {
|
||||
"identifier": acting_device_uuid,
|
||||
"type": UpdateType::LogOut as i32,
|
||||
"payload": {
|
||||
"userId": user.uuid,
|
||||
"date": user.updated_at
|
||||
"UserId": user.uuid,
|
||||
"Date": format_date(&user.updated_at)
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -204,8 +202,8 @@ pub fn push_user_update(ut: UpdateType, user: &User) {
|
||||
"identifier": (),
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"userId": user.uuid,
|
||||
"date": user.updated_at
|
||||
"UserId": user.uuid,
|
||||
"Date": format_date(&user.updated_at)
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -224,9 +222,9 @@ pub async fn push_folder_update(
|
||||
"identifier": acting_device_uuid,
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"id": folder.uuid,
|
||||
"userId": folder.user_uuid,
|
||||
"revisionDate": folder.updated_at
|
||||
"Id": folder.uuid,
|
||||
"UserId": folder.user_uuid,
|
||||
"RevisionDate": format_date(&folder.updated_at)
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -242,9 +240,9 @@ pub async fn push_send_update(ut: UpdateType, send: &Send, acting_device_uuid: &
|
||||
"identifier": acting_device_uuid,
|
||||
"type": ut as i32,
|
||||
"payload": {
|
||||
"id": send.uuid,
|
||||
"userId": send.user_uuid,
|
||||
"revisionDate": send.revision_date
|
||||
"Id": send.uuid,
|
||||
"UserId": send.user_uuid,
|
||||
"RevisionDate": format_date(&send.revision_date)
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -266,8 +264,15 @@ async fn send_to_push_relay(notification_data: Value) {
|
||||
|
||||
let auth_header = format!("Bearer {}", &auth_push_token);
|
||||
|
||||
if let Err(e) = get_reqwest_client()
|
||||
.post(CONFIG.push_relay_uri() + "/push/send")
|
||||
let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("An error occurred while sending a send update to the push relay: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = req
|
||||
.header(ACCEPT, "application/json")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(AUTHORIZATION, &auth_header)
|
||||
@@ -288,8 +293,8 @@ pub async fn push_auth_request(user_uuid: String, auth_request_uuid: String, con
|
||||
"identifier": null,
|
||||
"type": UpdateType::AuthRequest as i32,
|
||||
"payload": {
|
||||
"id": auth_request_uuid,
|
||||
"userId": user_uuid,
|
||||
"Id": auth_request_uuid,
|
||||
"UserId": user_uuid,
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -309,8 +314,8 @@ pub async fn push_auth_response(
|
||||
"identifier": approving_device_uuid,
|
||||
"type": UpdateType::AuthRequestResponse as i32,
|
||||
"payload": {
|
||||
"id": auth_request_uuid,
|
||||
"userId": user_uuid,
|
||||
"Id": auth_request_uuid,
|
||||
"UserId": user_uuid,
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
103
src/api/web.rs
103
src/api/web.rs
@@ -1,13 +1,20 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::{fs::NamedFile, http::ContentType, response::content::RawHtml as Html, serde::json::Json, Catcher, Route};
|
||||
use rocket::{
|
||||
fs::NamedFile,
|
||||
http::ContentType,
|
||||
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
|
||||
serde::json::Json,
|
||||
Catcher, Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{core::now, ApiResult, EmptyResult},
|
||||
auth::decode_file_download,
|
||||
error::Error,
|
||||
util::{Cached, SafeString},
|
||||
util::{get_web_vault_version, Cached, SafeString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -16,7 +23,7 @@ pub fn routes() -> Vec<Route> {
|
||||
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
|
||||
let mut routes = routes![attachments, alive, alive_head, static_files];
|
||||
if CONFIG.web_vault_enabled() {
|
||||
routes.append(&mut routes![web_index, web_index_head, app_id, web_files]);
|
||||
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -45,11 +52,101 @@ fn not_found() -> ApiResult<Html<String>> {
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[get("/css/vaultwarden.css")]
|
||||
fn vaultwarden_css() -> Cached<Css<String>> {
|
||||
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
|
||||
// The default is based upon the version since this feature is added.
|
||||
static WEB_VAULT_VERSION: Lazy<u32> = Lazy::new(|| {
|
||||
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||
let vault_version = get_web_vault_version();
|
||||
|
||||
let (major, minor, patch) = match re.captures(&vault_version) {
|
||||
Some(c) if c.len() == 4 => (
|
||||
c.get(1).unwrap().as_str().parse().unwrap(),
|
||||
c.get(2).unwrap().as_str().parse().unwrap(),
|
||||
c.get(3).unwrap().as_str().parse().unwrap(),
|
||||
),
|
||||
_ => (2024, 6, 2),
|
||||
};
|
||||
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
|
||||
});
|
||||
|
||||
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
|
||||
// The default is based upon the version since this feature is added.
|
||||
static VW_VERSION: Lazy<u32> = Lazy::new(|| {
|
||||
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||
let vw_version = crate::VERSION.unwrap_or("1.32.1");
|
||||
|
||||
let (major, minor, patch) = match re.captures(vw_version) {
|
||||
Some(c) if c.len() == 4 => (
|
||||
c.get(1).unwrap().as_str().parse().unwrap(),
|
||||
c.get(2).unwrap().as_str().parse().unwrap(),
|
||||
c.get(3).unwrap().as_str().parse().unwrap(),
|
||||
),
|
||||
_ => (1, 32, 1),
|
||||
};
|
||||
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
|
||||
});
|
||||
|
||||
let css_options = json!({
|
||||
"web_vault_version": *WEB_VAULT_VERSION,
|
||||
"vw_version": *VW_VERSION,
|
||||
"signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(),
|
||||
"mail_enabled": CONFIG.mail_enabled(),
|
||||
"yubico_enabled": CONFIG._enable_yubico() && (CONFIG.yubico_client_id().is_some() == CONFIG.yubico_secret_key().is_some()),
|
||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||
"sends_allowed": CONFIG.sends_allowed(),
|
||||
"load_user_scss": true,
|
||||
});
|
||||
|
||||
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
// Something went wrong loading the template. Use the fallback
|
||||
warn!("Loading scss/vaultwarden.scss.hbs or scss/user.vaultwarden.scss.hbs failed. {e}");
|
||||
CONFIG
|
||||
.render_fallback_template("scss/vaultwarden.scss", &css_options)
|
||||
.expect("Fallback scss/vaultwarden.scss.hbs to render")
|
||||
}
|
||||
};
|
||||
|
||||
let css = match grass_compiler::from_string(
|
||||
scss,
|
||||
&grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed),
|
||||
) {
|
||||
Ok(css) => css,
|
||||
Err(e) => {
|
||||
// Something went wrong compiling the scss. Use the fallback
|
||||
warn!("Compiling the Vaultwarden SCSS styles failed. {e}");
|
||||
let mut css_options = css_options;
|
||||
css_options["load_user_scss"] = json!(false);
|
||||
let scss = CONFIG
|
||||
.render_fallback_template("scss/vaultwarden.scss", &css_options)
|
||||
.expect("Fallback scss/vaultwarden.scss.hbs to render");
|
||||
grass_compiler::from_string(
|
||||
scss,
|
||||
&grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed),
|
||||
)
|
||||
.expect("SCSS to compile")
|
||||
}
|
||||
};
|
||||
|
||||
// Cache for one day should be enough and not too much
|
||||
Cached::ttl(Css(css), 86_400, false)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn web_index() -> Cached<Option<NamedFile>> {
|
||||
Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false)
|
||||
}
|
||||
|
||||
// Make sure that `/index.html` redirect to actual domain path.
|
||||
// If not, this might cause issues with the web-vault
|
||||
#[get("/index.html")]
|
||||
fn web_index_direct() -> Redirect {
|
||||
Redirect::to(format!("{}/", CONFIG.domain_path()))
|
||||
}
|
||||
|
||||
#[head("/")]
|
||||
fn web_index_head() -> EmptyResult {
|
||||
// Add an explicit HEAD route to prevent uptime monitoring services from
|
||||
|
120
src/auth.rs
120
src/auth.rs
@@ -1,13 +1,18 @@
|
||||
// JWT Handling
|
||||
//
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use num_traits::FromPrimitive;
|
||||
use once_cell::sync::{Lazy, OnceCell};
|
||||
|
||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use openssl::rsa::Rsa;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
net::IpAddr,
|
||||
};
|
||||
|
||||
use crate::{error::Error, CONFIG};
|
||||
|
||||
@@ -30,28 +35,37 @@ static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_do
|
||||
static PRIVATE_RSA_KEY: OnceCell<EncodingKey> = OnceCell::new();
|
||||
static PUBLIC_RSA_KEY: OnceCell<DecodingKey> = OnceCell::new();
|
||||
|
||||
pub fn initialize_keys() -> Result<(), crate::error::Error> {
|
||||
let mut priv_key_buffer = Vec::with_capacity(2048);
|
||||
pub fn initialize_keys() -> Result<(), Error> {
|
||||
fn read_key(create_if_missing: bool) -> Result<(Rsa<openssl::pkey::Private>, Vec<u8>), Error> {
|
||||
let mut priv_key_buffer = Vec::with_capacity(2048);
|
||||
|
||||
let priv_key = {
|
||||
let mut priv_key_file =
|
||||
File::options().create(true).truncate(false).read(true).write(true).open(CONFIG.private_rsa_key())?;
|
||||
let mut priv_key_file = File::options()
|
||||
.create(create_if_missing)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(create_if_missing)
|
||||
.open(CONFIG.private_rsa_key())?;
|
||||
|
||||
#[allow(clippy::verbose_file_reads)]
|
||||
let bytes_read = priv_key_file.read_to_end(&mut priv_key_buffer)?;
|
||||
|
||||
if bytes_read > 0 {
|
||||
let rsa_key = if bytes_read > 0 {
|
||||
Rsa::private_key_from_pem(&priv_key_buffer[..bytes_read])?
|
||||
} else {
|
||||
} else if create_if_missing {
|
||||
// Only create the key if the file doesn't exist or is empty
|
||||
let rsa_key = openssl::rsa::Rsa::generate(2048)?;
|
||||
let rsa_key = Rsa::generate(2048)?;
|
||||
priv_key_buffer = rsa_key.private_key_to_pem()?;
|
||||
priv_key_file.write_all(&priv_key_buffer)?;
|
||||
info!("Private key created correctly.");
|
||||
info!("Private key '{}' created correctly", CONFIG.private_rsa_key());
|
||||
rsa_key
|
||||
}
|
||||
};
|
||||
} else {
|
||||
err!("Private key does not exist or invalid format", CONFIG.private_rsa_key());
|
||||
};
|
||||
|
||||
Ok((rsa_key, priv_key_buffer))
|
||||
}
|
||||
|
||||
let (priv_key, priv_key_buffer) = read_key(true).or_else(|_| read_key(false))?;
|
||||
let pub_key_buffer = priv_key.public_key_to_pem()?;
|
||||
|
||||
let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?;
|
||||
@@ -379,8 +393,6 @@ impl<'r> FromRequest<'r> for Host {
|
||||
referer.to_string()
|
||||
} else {
|
||||
// Try to guess from the headers
|
||||
use std::env;
|
||||
|
||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||
proto
|
||||
} else if env::var("ROCKET_TLS").is_ok() {
|
||||
@@ -459,9 +471,8 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
};
|
||||
|
||||
// Check JWT token is valid and get device and user from it
|
||||
let claims = match decode_login(access_token) {
|
||||
Ok(claims) => claims,
|
||||
Err(_) => err_handler!("Invalid claim"),
|
||||
let Ok(claims) = decode_login(access_token) else {
|
||||
err_handler!("Invalid claim")
|
||||
};
|
||||
|
||||
let device_uuid = claims.device;
|
||||
@@ -472,23 +483,20 @@ impl<'r> FromRequest<'r> for Headers {
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
|
||||
let device = match Device::find_by_uuid_and_user(&device_uuid, &user_uuid, &mut conn).await {
|
||||
Some(device) => device,
|
||||
None => err_handler!("Invalid device id"),
|
||||
let Some(device) = Device::find_by_uuid_and_user(&device_uuid, &user_uuid, &mut conn).await else {
|
||||
err_handler!("Invalid device id")
|
||||
};
|
||||
|
||||
let user = match User::find_by_uuid(&user_uuid, &mut conn).await {
|
||||
Some(user) => user,
|
||||
None => err_handler!("Device has no user associated"),
|
||||
let Some(user) = User::find_by_uuid(&user_uuid, &mut conn).await else {
|
||||
err_handler!("Device has no user associated")
|
||||
};
|
||||
|
||||
if user.security_stamp != claims.sstamp {
|
||||
if let Some(stamp_exception) =
|
||||
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
|
||||
{
|
||||
let current_route = match request.route().and_then(|r| r.name.as_deref()) {
|
||||
Some(name) => name,
|
||||
_ => err_handler!("Error getting current route for stamp exception"),
|
||||
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
||||
err_handler!("Error getting current route for stamp exception")
|
||||
};
|
||||
|
||||
// Check if the stamp exception has expired first.
|
||||
@@ -603,7 +611,6 @@ pub struct AdminHeaders {
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub org_user_type: UserOrgType,
|
||||
pub client_version: Option<String>,
|
||||
pub ip: ClientIp,
|
||||
}
|
||||
|
||||
@@ -613,14 +620,12 @@ impl<'r> FromRequest<'r> for AdminHeaders {
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
let client_version = request.headers().get_one("Bitwarden-Client-Version").map(String::from);
|
||||
if headers.org_user_type >= UserOrgType::Admin {
|
||||
Outcome::Success(Self {
|
||||
host: headers.host,
|
||||
device: headers.device,
|
||||
user: headers.user,
|
||||
org_user_type: headers.org_user_type,
|
||||
client_version,
|
||||
ip: headers.ip,
|
||||
})
|
||||
} else {
|
||||
@@ -805,11 +810,6 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
|
||||
//
|
||||
// Client IP address detection
|
||||
//
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
net::IpAddr,
|
||||
};
|
||||
|
||||
pub struct ClientIp {
|
||||
pub ip: IpAddr,
|
||||
@@ -842,6 +842,35 @@ impl<'r> FromRequest<'r> for ClientIp {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Secure {
|
||||
pub https: bool,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for Secure {
|
||||
type Error = ();
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
|
||||
// Try to guess from the headers
|
||||
let protocol = match headers.get_one("X-Forwarded-Proto") {
|
||||
Some(proto) => proto,
|
||||
None => {
|
||||
if env::var("ROCKET_TLS").is_ok() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Outcome::Success(Secure {
|
||||
https: protocol == "https",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WsAccessTokenHeader {
|
||||
pub access_token: Option<String>,
|
||||
}
|
||||
@@ -864,3 +893,24 @@ impl<'r> FromRequest<'r> for WsAccessTokenHeader {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClientVersion(pub semver::Version);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for ClientVersion {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
|
||||
let Some(version) = headers.get_one("Bitwarden-Client-Version") else {
|
||||
err_handler!("No Bitwarden-Client-Version header provided")
|
||||
};
|
||||
|
||||
let Ok(version) = semver::Version::parse(version) else {
|
||||
err_handler!("Invalid Bitwarden-Client-Version header provided")
|
||||
};
|
||||
|
||||
Outcome::Success(ClientVersion(version))
|
||||
}
|
||||
}
|
||||
|
176
src/config.rs
176
src/config.rs
@@ -1,6 +1,9 @@
|
||||
use std::env::consts::EXE_SUFFIX;
|
||||
use std::process::exit;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
RwLock,
|
||||
};
|
||||
|
||||
use job_scheduler_ng::Schedule;
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -17,6 +20,8 @@ static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
|
||||
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json"))
|
||||
});
|
||||
|
||||
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||
Config::load().unwrap_or_else(|e| {
|
||||
println!("Error loading config:\n {e:?}\n");
|
||||
@@ -146,6 +151,12 @@ macro_rules! make_config {
|
||||
config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase();
|
||||
config.org_creation_users = config.org_creation_users.trim().to_lowercase();
|
||||
|
||||
|
||||
// Copy the values from the deprecated flags to the new ones
|
||||
if config.http_request_block_regex.is_none() {
|
||||
config.http_request_block_regex = config.icon_blacklist_regex.clone();
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
@@ -227,6 +238,7 @@ macro_rules! make_config {
|
||||
// Besides Pass, only String types will be masked via _privacy_mask.
|
||||
const PRIVACY_CONFIG: &[&str] = &[
|
||||
"allowed_iframe_ancestors",
|
||||
"allowed_connect_src",
|
||||
"database_url",
|
||||
"domain_origin",
|
||||
"domain_path",
|
||||
@@ -237,6 +249,7 @@ macro_rules! make_config {
|
||||
"smtp_from",
|
||||
"smtp_host",
|
||||
"smtp_username",
|
||||
"_smtp_img_src",
|
||||
];
|
||||
|
||||
let cfg = {
|
||||
@@ -325,7 +338,7 @@ macro_rules! make_config {
|
||||
}
|
||||
}
|
||||
}};
|
||||
( @build $value:expr, $config:expr, gen, $default_fn:expr ) => {{
|
||||
( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{
|
||||
let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn;
|
||||
f($config)
|
||||
}};
|
||||
@@ -343,10 +356,10 @@ macro_rules! make_config {
|
||||
// }
|
||||
//
|
||||
// Where action applied when the value wasn't provided and can be:
|
||||
// def: Use a default value
|
||||
// auto: Value is auto generated based on other values
|
||||
// option: Value is optional
|
||||
// gen: Value is always autogenerated and it's original value ignored
|
||||
// def: Use a default value
|
||||
// auto: Value is auto generated based on other values
|
||||
// option: Value is optional
|
||||
// generated: Value is always autogenerated and it's original value ignored
|
||||
make_config! {
|
||||
folders {
|
||||
/// Data folder |> Main data folder
|
||||
@@ -409,7 +422,9 @@ make_config! {
|
||||
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
||||
/// Defaults to every minute. Set blank to disable this job.
|
||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
|
||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||
/// Defaults to once every minute. Set blank to disable this job.
|
||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
},
|
||||
|
||||
/// General settings
|
||||
@@ -484,11 +499,11 @@ make_config! {
|
||||
/// Password iterations |> Number of server-side passwords hashing iterations for the password hash.
|
||||
/// The default for new users. If changed, it will be updated during login for existing users.
|
||||
password_iterations: i32, true, def, 600_000;
|
||||
/// Allow password hints |> Controls whether users can set password hints. This setting applies globally to all users.
|
||||
/// Allow password hints |> Controls whether users can set or show password hints. This setting applies globally to all users.
|
||||
password_hints_allowed: bool, true, def, true;
|
||||
/// Show password hint |> Controls whether a password hint should be shown directly in the web page
|
||||
/// if SMTP service is not configured. Not recommended for publicly-accessible instances as this
|
||||
/// provides unauthenticated access to potentially sensitive data.
|
||||
/// Show password hint (Know the risks!) |> Controls whether a password hint should be shown directly in the web page
|
||||
/// if SMTP service is not configured and password hints are allowed. Not recommended for publicly-accessible instances
|
||||
/// because this provides unauthenticated access to potentially sensitive data.
|
||||
show_password_hint: bool, true, def, false;
|
||||
|
||||
/// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session!
|
||||
@@ -507,7 +522,7 @@ make_config! {
|
||||
/// Set to the string "none" (without quotes), to disable any headers and just use the remote IP
|
||||
ip_header: String, true, def, "X-Real-IP".to_string();
|
||||
/// Internal IP header property, used to avoid recomputing each time
|
||||
_ip_header_enabled: bool, false, gen, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
|
||||
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
|
||||
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
|
||||
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
|
||||
@@ -516,9 +531,9 @@ make_config! {
|
||||
/// corresponding icon at the external service.
|
||||
icon_service: String, false, def, "internal".to_string();
|
||||
/// _icon_service_url
|
||||
_icon_service_url: String, false, gen, |c| generate_icon_service_url(&c.icon_service);
|
||||
_icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service);
|
||||
/// _icon_service_csp
|
||||
_icon_service_csp: String, false, gen, |c| generate_icon_service_csp(&c.icon_service, &c._icon_service_url);
|
||||
_icon_service_csp: String, false, generated, |c| generate_icon_service_csp(&c.icon_service, &c._icon_service_url);
|
||||
/// Icon redirect code |> The HTTP status code to use for redirects to an external icon service.
|
||||
/// The supported codes are 301 (legacy permanent), 302 (legacy temporary), 307 (temporary), and 308 (permanent).
|
||||
/// Temporary redirects are useful while testing different icon services, but once a service
|
||||
@@ -531,12 +546,18 @@ make_config! {
|
||||
icon_cache_negttl: u64, true, def, 259_200;
|
||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||
icon_download_timeout: u64, true, def, 10;
|
||||
/// Icon blacklist Regex |> Any domains or IPs that match this regex won't be fetched by the icon service.
|
||||
|
||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||
icon_blacklist_regex: String, false, option;
|
||||
/// [Deprecated] Icon blacklist non global IPs |> Use `http_request_block_non_global_ips` instead
|
||||
icon_blacklist_non_global_ips: bool, false, def, true;
|
||||
|
||||
/// Block HTTP domains/IPs by Regex |> Any domains or IPs that match this regex won't be fetched by the internal HTTP client.
|
||||
/// Useful to hide other servers in the local network. Check the WIKI for more details
|
||||
icon_blacklist_regex: String, true, option;
|
||||
/// Icon blacklist non global IPs |> Any IP which is not defined as a global IP will be blacklisted.
|
||||
http_request_block_regex: String, true, option;
|
||||
/// Block non global IPs |> Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address.
|
||||
/// Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block
|
||||
icon_blacklist_non_global_ips: bool, true, def, true;
|
||||
http_request_block_non_global_ips: bool, true, auto, |c| c.icon_blacklist_non_global_ips;
|
||||
|
||||
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
||||
/// Note that the checkbox would still be present, but ignored.
|
||||
@@ -564,8 +585,9 @@ make_config! {
|
||||
use_syslog: bool, false, def, false;
|
||||
/// Log file path
|
||||
log_file: String, false, option;
|
||||
/// Log level
|
||||
log_level: String, false, def, "Info".to_string();
|
||||
/// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off"
|
||||
/// For a specific module append it as a comma separated value "info,path::to::module=debug"
|
||||
log_level: String, false, def, "info".to_string();
|
||||
|
||||
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems,
|
||||
/// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
|
||||
@@ -589,6 +611,9 @@ make_config! {
|
||||
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
||||
allowed_iframe_ancestors: String, true, def, String::new();
|
||||
|
||||
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||
allowed_connect_src: String, true, def, String::new();
|
||||
|
||||
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
||||
login_ratelimit_seconds: u64, false, def, 60;
|
||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||
@@ -603,7 +628,18 @@ make_config! {
|
||||
admin_session_lifetime: i64, true, def, 20;
|
||||
|
||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
||||
org_groups_enabled: bool, false, def, false;
|
||||
org_groups_enabled: bool, false, def, false;
|
||||
|
||||
/// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000.
|
||||
/// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers!
|
||||
increase_note_size_limit: bool, true, def, false;
|
||||
/// Generated max_note_size value to prevent if..else matching during every check
|
||||
_max_note_size: usize, false, generated, |c| if c.increase_note_size_limit {100_000} else {10_000};
|
||||
|
||||
/// Enforce Single Org with Reset Password Policy |> Enforce that the Single Org policy is enabled before setting the Reset Password policy
|
||||
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
|
||||
/// 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: bool, false, def, false;
|
||||
},
|
||||
|
||||
/// Yubikey settings
|
||||
@@ -622,6 +658,8 @@ make_config! {
|
||||
duo: _enable_duo {
|
||||
/// Enabled
|
||||
_enable_duo: bool, true, def, true;
|
||||
/// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2)
|
||||
duo_use_iframe: bool, false, def, false;
|
||||
/// Integration Key
|
||||
duo_ikey: String, true, option;
|
||||
/// Secret Key
|
||||
@@ -667,7 +705,7 @@ make_config! {
|
||||
/// Embed images as email attachments.
|
||||
smtp_embed_images: bool, true, def, true;
|
||||
/// _smtp_img_src
|
||||
_smtp_img_src: String, false, gen, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
||||
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
||||
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
|
||||
smtp_debug: bool, false, def, false;
|
||||
/// Accept Invalid Certs (Know the risks!) |> DANGEROUS: Allow invalid certificates. This option introduces significant vulnerabilities to man-in-the-middle attacks!
|
||||
@@ -727,6 +765,13 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
);
|
||||
}
|
||||
|
||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||
for url in connect_src.split_whitespace() {
|
||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||
err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed");
|
||||
}
|
||||
}
|
||||
|
||||
let whitelist = &cfg.signups_domains_whitelist;
|
||||
if !whitelist.is_empty() && whitelist.split(',').any(|d| d.trim().is_empty()) {
|
||||
err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens");
|
||||
@@ -778,8 +823,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
}
|
||||
|
||||
// TODO: deal with deprecated flags so they can be removed from this list, cf. #4263
|
||||
const KNOWN_FLAGS: &[&str] =
|
||||
&["autofill-overlay", "autofill-v2", "browser-fileless-import", "fido2-vault-credentials"];
|
||||
const KNOWN_FLAGS: &[&str] = &[
|
||||
"autofill-overlay",
|
||||
"autofill-v2",
|
||||
"browser-fileless-import",
|
||||
"extension-refresh",
|
||||
"fido2-vault-credentials",
|
||||
"ssh-key-vault-item",
|
||||
"ssh-agent",
|
||||
];
|
||||
let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags);
|
||||
let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect();
|
||||
if !invalid_flags.is_empty() {
|
||||
@@ -899,12 +951,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!("To use email 2FA as automatic fallback, email 2fa has to be enabled!");
|
||||
}
|
||||
|
||||
// Check if the icon blacklist regex is valid
|
||||
if let Some(ref r) = cfg.icon_blacklist_regex {
|
||||
// Check if the HTTP request block regex is valid
|
||||
if let Some(ref r) = cfg.http_request_block_regex {
|
||||
let validate_regex = regex::Regex::new(r);
|
||||
match validate_regex {
|
||||
Ok(_) => (),
|
||||
Err(e) => err!(format!("`ICON_BLACKLIST_REGEX` is invalid: {e:#?}")),
|
||||
Err(e) => err!(format!("`HTTP_REQUEST_BLOCK_REGEX` is invalid: {e:#?}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,6 +1036,11 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.increase_note_size_limit {
|
||||
println!("[WARNING] Secure Note size limit is increased to 100_000!");
|
||||
println!("[WARNING] This could cause issues with clients. Also exports will not work on Bitwarden servers!.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1072,7 +1129,9 @@ impl Config {
|
||||
|
||||
// Fill any missing with defaults
|
||||
let config = builder.build();
|
||||
validate_config(&config)?;
|
||||
if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) {
|
||||
validate_config(&config)?;
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
inner: RwLock::new(Inner {
|
||||
@@ -1192,7 +1251,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn private_rsa_key(&self) -> String {
|
||||
format!("{}.pem", CONFIG.rsa_key_filename())
|
||||
format!("{}.pem", self.rsa_key_filename())
|
||||
}
|
||||
pub fn mail_enabled(&self) -> bool {
|
||||
let inner = &self.inner.read().unwrap().config;
|
||||
@@ -1223,21 +1282,22 @@ impl Config {
|
||||
token.is_some() && !token.unwrap().trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn render_template<T: serde::ser::Serialize>(
|
||||
&self,
|
||||
name: &str,
|
||||
data: &T,
|
||||
) -> Result<String, crate::error::Error> {
|
||||
if CONFIG.reload_templates() {
|
||||
pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||
if self.reload_templates() {
|
||||
warn!("RELOADING TEMPLATES");
|
||||
let hb = load_templates(CONFIG.templates_folder());
|
||||
hb.render(name, data).map_err(Into::into)
|
||||
} else {
|
||||
let hb = &CONFIG.inner.read().unwrap().templates;
|
||||
let hb = &self.inner.read().unwrap().templates;
|
||||
hb.render(name, data).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_fallback_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
|
||||
let hb = &self.inner.read().unwrap().templates;
|
||||
hb.render(&format!("fallback_{name}"), data).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn set_rocket_shutdown_handle(&self, handle: rocket::Shutdown) {
|
||||
self.inner.write().unwrap().rocket_shutdown_handle = Some(handle);
|
||||
}
|
||||
@@ -1265,7 +1325,6 @@ where
|
||||
hb.set_strict_mode(true);
|
||||
// Register helpers
|
||||
hb.register_helper("case", Box::new(case_helper));
|
||||
hb.register_helper("jsesc", Box::new(js_escape_helper));
|
||||
hb.register_helper("to_json", Box::new(to_json));
|
||||
|
||||
macro_rules! reg {
|
||||
@@ -1277,6 +1336,11 @@ where
|
||||
reg!($name);
|
||||
reg!(concat!($name, $ext));
|
||||
}};
|
||||
(@withfallback $name:expr) => {{
|
||||
let template = include_str!(concat!("static/templates/", $name, ".hbs"));
|
||||
hb.register_template_string($name, template).unwrap();
|
||||
hb.register_template_string(concat!("fallback_", $name), template).unwrap();
|
||||
}};
|
||||
}
|
||||
|
||||
// First register default templates here
|
||||
@@ -1320,17 +1384,13 @@ where
|
||||
|
||||
reg!("404");
|
||||
|
||||
reg!(@withfallback "scss/vaultwarden.scss");
|
||||
reg!("scss/user.vaultwarden.scss");
|
||||
|
||||
// And then load user templates to overwrite the defaults
|
||||
// Use .hbs extension for the files
|
||||
// Templates get registered with their relative name
|
||||
hb.register_templates_directory(
|
||||
path,
|
||||
DirectorySourceOptions {
|
||||
tpl_extension: ".hbs".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
hb.register_templates_directory(path, DirectorySourceOptions::default()).unwrap();
|
||||
|
||||
hb
|
||||
}
|
||||
@@ -1353,32 +1413,6 @@ fn case_helper<'reg, 'rc>(
|
||||
}
|
||||
}
|
||||
|
||||
fn js_escape_helper<'reg, 'rc>(
|
||||
h: &Helper<'rc>,
|
||||
_r: &'reg Handlebars<'_>,
|
||||
_ctx: &'rc Context,
|
||||
_rc: &mut RenderContext<'reg, 'rc>,
|
||||
out: &mut dyn Output,
|
||||
) -> HelperResult {
|
||||
let param =
|
||||
h.param(0).ok_or_else(|| RenderErrorReason::Other(String::from("Param not found for helper \"jsesc\"")))?;
|
||||
|
||||
let no_quote = h.param(1).is_some();
|
||||
|
||||
let value = param
|
||||
.value()
|
||||
.as_str()
|
||||
.ok_or_else(|| RenderErrorReason::Other(String::from("Param for helper \"jsesc\" is not a String")))?;
|
||||
|
||||
let mut escaped_value = value.replace('\\', "").replace('\'', "\\x22").replace('\"', "\\x27");
|
||||
if !no_quote {
|
||||
escaped_value = format!(""{escaped_value}"");
|
||||
}
|
||||
|
||||
out.write(&escaped_value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_json<'reg, 'rc>(
|
||||
h: &Helper<'rc>,
|
||||
_r: &'reg Handlebars<'_>,
|
||||
|
@@ -300,19 +300,17 @@ pub trait FromDb {
|
||||
|
||||
impl<T: FromDb> FromDb for Vec<T> {
|
||||
type Output = Vec<T::Output>;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
#[inline(always)]
|
||||
fn from_db(self) -> Self::Output {
|
||||
self.into_iter().map(crate::db::FromDb::from_db).collect()
|
||||
self.into_iter().map(FromDb::from_db).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: FromDb> FromDb for Option<T> {
|
||||
type Output = Option<T::Output>;
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
#[inline(always)]
|
||||
fn from_db(self) -> Self::Output {
|
||||
self.map(crate::db::FromDb::from_db)
|
||||
self.map(FromDb::from_db)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,19 +366,21 @@ pub mod models;
|
||||
|
||||
/// Creates a back-up of the sqlite database
|
||||
/// MySQL/MariaDB and PostgreSQL are not supported.
|
||||
pub async fn backup_database(conn: &mut DbConn) -> Result<(), Error> {
|
||||
pub async fn backup_database(conn: &mut DbConn) -> Result<String, Error> {
|
||||
db_run! {@raw conn:
|
||||
postgresql, mysql {
|
||||
let _ = conn;
|
||||
err!("PostgreSQL and MySQL/MariaDB do not support this backup feature");
|
||||
}
|
||||
sqlite {
|
||||
use std::path::Path;
|
||||
let db_url = CONFIG.database_url();
|
||||
let db_path = Path::new(&db_url).parent().unwrap().to_string_lossy();
|
||||
let file_date = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
diesel::sql_query(format!("VACUUM INTO '{db_path}/db_{file_date}.sqlite3'")).execute(conn)?;
|
||||
Ok(())
|
||||
let db_path = std::path::Path::new(&db_url).parent().unwrap();
|
||||
let backup_file = db_path
|
||||
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
diesel::sql_query(format!("VACUUM INTO '{backup_file}'")).execute(conn)?;
|
||||
Ok(backup_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -111,6 +111,17 @@ impl AuthRequest {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::uuid.eq(uuid))
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
.first::<AuthRequestDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
|
@@ -30,7 +30,8 @@ db_object! {
|
||||
Login = 1,
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4
|
||||
Identity = 4,
|
||||
SshKey = 5
|
||||
*/
|
||||
pub atype: i32,
|
||||
pub name: String,
|
||||
@@ -45,10 +46,9 @@ db_object! {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum RepromptType {
|
||||
None = 0,
|
||||
Password = 1, // not currently used in server
|
||||
Password = 1,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
@@ -79,21 +79,39 @@ impl Cipher {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_notes(cipher_data: &[CipherData]) -> EmptyResult {
|
||||
pub fn validate_cipher_data(cipher_data: &[CipherData]) -> EmptyResult {
|
||||
let mut validation_errors = serde_json::Map::new();
|
||||
let max_note_size = CONFIG._max_note_size();
|
||||
let max_note_size_msg =
|
||||
format!("The field Notes exceeds the maximum encrypted value length of {} characters.", &max_note_size);
|
||||
for (index, cipher) in cipher_data.iter().enumerate() {
|
||||
// Validate the note size and if it is exceeded return a warning
|
||||
if let Some(note) = &cipher.notes {
|
||||
if note.len() > 10_000 {
|
||||
validation_errors.insert(
|
||||
format!("Ciphers[{index}].Notes"),
|
||||
serde_json::to_value([
|
||||
"The field Notes exceeds the maximum encrypted value length of 10000 characters.",
|
||||
])
|
||||
.unwrap(),
|
||||
);
|
||||
if note.len() > max_note_size {
|
||||
validation_errors
|
||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the password history if it contains `null` values and if so, return a warning
|
||||
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
||||
for pwh in password_history {
|
||||
if let Value::Object(pwo) = pwh {
|
||||
if pwo.get("password").is_some_and(|p| !p.is_string()) {
|
||||
validation_errors.insert(
|
||||
format!("Ciphers[{index}].Notes"),
|
||||
serde_json::to_value([
|
||||
"The password history contains a `null` value. Only strings are allowed.",
|
||||
])
|
||||
.unwrap(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !validation_errors.is_empty() {
|
||||
let err_json = json!({
|
||||
"message": "The model state is invalid.",
|
||||
@@ -155,27 +173,70 @@ impl Cipher {
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing fields {:?}", e))
|
||||
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
})
|
||||
.map(|d| d.into_iter().map(|d| d.data).collect())
|
||||
.map(|d| {
|
||||
d.into_iter()
|
||||
.map(|mut f| {
|
||||
// Check if the `type` key is a number, strings break some clients
|
||||
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
|
||||
// If not try to convert the string value to a number and fallback to `1`
|
||||
// If it is both not a number and not a string, fallback to `1`
|
||||
match f.data.get("type") {
|
||||
Some(t) if t.is_number() => {}
|
||||
Some(t) if t.is_string() => {
|
||||
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
|
||||
f.data["type"] = json!(type_num);
|
||||
}
|
||||
_ => {
|
||||
f.data["type"] = json!(1);
|
||||
}
|
||||
}
|
||||
f.data
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let password_history_json: Vec<_> = self
|
||||
.password_history
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
|
||||
.inspect_err(|e| warn!("Error parsing password history {:?}", e))
|
||||
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
|
||||
.ok()
|
||||
})
|
||||
.map(|d| d.into_iter().map(|d| d.data).collect())
|
||||
.map(|d| {
|
||||
// Check every password history item if they are valid and return it.
|
||||
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
|
||||
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
|
||||
d.into_iter()
|
||||
.filter_map(|d| match d.data.get("password") {
|
||||
Some(p) if p.is_string() => Some(d.data),
|
||||
_ => None,
|
||||
})
|
||||
.map(|mut d| match d.get("lastUsedDate").and_then(|l| l.as_str()) {
|
||||
Some(l) => {
|
||||
d["lastUsedDate"] = json!(crate::util::validate_and_format_date(l));
|
||||
d
|
||||
}
|
||||
_ => {
|
||||
d["lastUsedDate"] = json!("1970-01-01T00:00:00.000000Z");
|
||||
d
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get the type_data or a default to an empty json object '{}'.
|
||||
// If not passing an empty object, mobile clients will crash.
|
||||
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
|
||||
.map(|d| d.data)
|
||||
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
|
||||
let mut type_data_json =
|
||||
serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data).unwrap_or_else(|_| {
|
||||
warn!("Error parsing data field for {}", self.uuid);
|
||||
Value::Object(serde_json::Map::new())
|
||||
});
|
||||
|
||||
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
||||
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
|
||||
@@ -189,10 +250,15 @@ impl Cipher {
|
||||
}
|
||||
}
|
||||
|
||||
// Fix secure note issues when data is `{}`
|
||||
// Fix secure note issues when data is invalid
|
||||
// This breaks at least the native mobile clients
|
||||
if self.atype == 2 && (self.data.eq("{}") || self.data.to_ascii_lowercase().eq("{\"type\":null}")) {
|
||||
type_data_json = json!({"type": 0});
|
||||
if self.atype == 2 {
|
||||
match type_data_json {
|
||||
Value::Object(ref t) if t.get("type").is_some_and(|t| t.is_number()) => {}
|
||||
_ => {
|
||||
type_data_json = json!({"type": 0});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the type_data and add some default value.
|
||||
@@ -200,7 +266,7 @@ impl Cipher {
|
||||
|
||||
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
|
||||
// data_json should always contain the following keys with every atype
|
||||
data_json["fields"] = Value::Array(fields_json.clone());
|
||||
data_json["fields"] = json!(fields_json);
|
||||
data_json["name"] = json!(self.name);
|
||||
data_json["notes"] = json!(self.notes);
|
||||
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
|
||||
@@ -229,7 +295,7 @@ impl Cipher {
|
||||
"creationDate": format_date(&self.created_at),
|
||||
"revisionDate": format_date(&self.updated_at),
|
||||
"deletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
|
||||
"reprompt": self.reprompt.unwrap_or(RepromptType::None as i32),
|
||||
"reprompt": self.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32).unwrap_or(RepromptType::None as i32),
|
||||
"organizationId": self.organization_uuid,
|
||||
"key": self.key,
|
||||
"attachments": attachments_json,
|
||||
@@ -253,6 +319,7 @@ impl Cipher {
|
||||
"secureNote": null,
|
||||
"card": null,
|
||||
"identity": null,
|
||||
"sshKey": null,
|
||||
});
|
||||
|
||||
// These values are only needed for user/default syncs
|
||||
@@ -281,6 +348,7 @@ impl Cipher {
|
||||
2 => "secureNote",
|
||||
3 => "card",
|
||||
4 => "identity",
|
||||
5 => "sshKey",
|
||||
_ => panic!("Wrong type"),
|
||||
};
|
||||
|
||||
@@ -620,6 +688,17 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_org(cipher_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(ciphers::uuid.eq(cipher_uuid))
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
.first::<CipherDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
// Find all ciphers accessible or visible to the specified user.
|
||||
//
|
||||
// "Accessible" means the user has read access to the cipher, either via
|
||||
|
@@ -78,28 +78,46 @@ impl Collection {
|
||||
cipher_sync_data: Option<&crate::api::core::CipherSyncData>,
|
||||
conn: &mut DbConn,
|
||||
) -> Value {
|
||||
let (read_only, hide_passwords) = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
let (read_only, hide_passwords, can_manage) = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
match cipher_sync_data.user_organizations.get(&self.org_uuid) {
|
||||
Some(uo) if uo.has_full_access() => (false, false),
|
||||
Some(_) => {
|
||||
// Only for Manager types Bitwarden returns true for the can_manage option
|
||||
// Owners and Admins always have true
|
||||
Some(uo) if uo.has_full_access() => (false, false, uo.atype >= UserOrgType::Manager),
|
||||
Some(uo) => {
|
||||
// Only let a manager manage collections when the have full read/write access
|
||||
let is_manager = uo.atype == UserOrgType::Manager;
|
||||
if let Some(uc) = cipher_sync_data.user_collections.get(&self.uuid) {
|
||||
(uc.read_only, uc.hide_passwords)
|
||||
(uc.read_only, uc.hide_passwords, is_manager && !uc.read_only && !uc.hide_passwords)
|
||||
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
|
||||
(cg.read_only, cg.hide_passwords)
|
||||
(cg.read_only, cg.hide_passwords, is_manager && !cg.read_only && !cg.hide_passwords)
|
||||
} else {
|
||||
(false, false)
|
||||
(false, false, false)
|
||||
}
|
||||
}
|
||||
_ => (true, true),
|
||||
_ => (true, true, false),
|
||||
}
|
||||
} else {
|
||||
(!self.is_writable_by_user(user_uuid, conn).await, self.hide_passwords_for_user(user_uuid, conn).await)
|
||||
match UserOrganization::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await {
|
||||
Some(ou) if ou.has_full_access() => (false, false, ou.atype >= UserOrgType::Manager),
|
||||
Some(ou) => {
|
||||
let is_manager = ou.atype == UserOrgType::Manager;
|
||||
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
|
||||
let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await;
|
||||
(read_only, hide_passwords, is_manager && !read_only && !hide_passwords)
|
||||
}
|
||||
_ => (
|
||||
!self.is_writable_by_user(user_uuid, conn).await,
|
||||
self.hide_passwords_for_user(user_uuid, conn).await,
|
||||
false,
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let mut json_object = self.to_json();
|
||||
json_object["object"] = json!("collectionDetails");
|
||||
json_object["readOnly"] = json!(read_only);
|
||||
json_object["hidePasswords"] = json!(hide_passwords);
|
||||
json_object["manage"] = json!(can_manage);
|
||||
json_object
|
||||
}
|
||||
|
||||
|
@@ -16,7 +16,7 @@ db_object! {
|
||||
pub user_uuid: String,
|
||||
|
||||
pub name: String,
|
||||
pub atype: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs
|
||||
pub atype: i32, // https://github.com/bitwarden/server/blob/dcc199bcce4aa2d5621f6fab80f1b49d8b143418/src/Core/Enums/DeviceType.cs
|
||||
pub push_uuid: Option<String>,
|
||||
pub push_token: Option<String>,
|
||||
|
||||
@@ -267,6 +267,9 @@ pub enum DeviceType {
|
||||
SafariExtension = 20,
|
||||
Sdk = 21,
|
||||
Server = 22,
|
||||
WindowsCLI = 23,
|
||||
MacOsCLI = 24,
|
||||
LinuxCLI = 25,
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
@@ -278,23 +281,26 @@ impl fmt::Display for DeviceType {
|
||||
DeviceType::FirefoxExtension => write!(f, "Firefox Extension"),
|
||||
DeviceType::OperaExtension => write!(f, "Opera Extension"),
|
||||
DeviceType::EdgeExtension => write!(f, "Edge Extension"),
|
||||
DeviceType::WindowsDesktop => write!(f, "Windows Desktop"),
|
||||
DeviceType::MacOsDesktop => write!(f, "MacOS Desktop"),
|
||||
DeviceType::LinuxDesktop => write!(f, "Linux Desktop"),
|
||||
DeviceType::ChromeBrowser => write!(f, "Chrome Browser"),
|
||||
DeviceType::FirefoxBrowser => write!(f, "Firefox Browser"),
|
||||
DeviceType::OperaBrowser => write!(f, "Opera Browser"),
|
||||
DeviceType::EdgeBrowser => write!(f, "Edge Browser"),
|
||||
DeviceType::WindowsDesktop => write!(f, "Windows"),
|
||||
DeviceType::MacOsDesktop => write!(f, "macOS"),
|
||||
DeviceType::LinuxDesktop => write!(f, "Linux"),
|
||||
DeviceType::ChromeBrowser => write!(f, "Chrome"),
|
||||
DeviceType::FirefoxBrowser => write!(f, "Firefox"),
|
||||
DeviceType::OperaBrowser => write!(f, "Opera"),
|
||||
DeviceType::EdgeBrowser => write!(f, "Edge"),
|
||||
DeviceType::IEBrowser => write!(f, "Internet Explorer"),
|
||||
DeviceType::UnknownBrowser => write!(f, "Unknown Browser"),
|
||||
DeviceType::AndroidAmazon => write!(f, "Android Amazon"),
|
||||
DeviceType::AndroidAmazon => write!(f, "Android"),
|
||||
DeviceType::Uwp => write!(f, "UWP"),
|
||||
DeviceType::SafariBrowser => write!(f, "Safari Browser"),
|
||||
DeviceType::VivaldiBrowser => write!(f, "Vivaldi Browser"),
|
||||
DeviceType::SafariBrowser => write!(f, "Safari"),
|
||||
DeviceType::VivaldiBrowser => write!(f, "Vivaldi"),
|
||||
DeviceType::VivaldiExtension => write!(f, "Vivaldi Extension"),
|
||||
DeviceType::SafariExtension => write!(f, "Safari Extension"),
|
||||
DeviceType::Sdk => write!(f, "SDK"),
|
||||
DeviceType::Server => write!(f, "Server"),
|
||||
DeviceType::WindowsCLI => write!(f, "Windows CLI"),
|
||||
DeviceType::MacOsCLI => write!(f, "macOS CLI"),
|
||||
DeviceType::LinuxCLI => write!(f, "Linux CLI"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +331,9 @@ impl DeviceType {
|
||||
20 => DeviceType::SafariExtension,
|
||||
21 => DeviceType::Sdk,
|
||||
22 => DeviceType::Server,
|
||||
23 => DeviceType::WindowsCLI,
|
||||
24 => DeviceType::MacOsCLI,
|
||||
25 => DeviceType::LinuxCLI,
|
||||
_ => DeviceType::UnknownBrowser,
|
||||
}
|
||||
}
|
||||
|
@@ -26,7 +26,7 @@ db_object! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
// Local methods
|
||||
|
||||
impl EmergencyAccess {
|
||||
pub fn new(grantor_uuid: String, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self {
|
||||
@@ -89,7 +89,7 @@ impl EmergencyAccess {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
// remove outstanding invitations which should not exist
|
||||
let _ = Self::delete_all_by_grantee_email(email, conn).await;
|
||||
Self::delete_all_by_grantee_email(email, conn).await.ok();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
@@ -120,10 +120,11 @@ impl Folder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
folders::table
|
||||
.filter(folders::uuid.eq(uuid))
|
||||
.filter(folders::user_uuid.eq(user_uuid))
|
||||
.first::<FolderDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
|
@@ -1,3 +1,7 @@
|
||||
use super::{User, UserOrganization};
|
||||
use crate::api::EmptyResult;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -77,7 +81,8 @@ impl Group {
|
||||
json!({
|
||||
"id": entry.collections_uuid,
|
||||
"readOnly": entry.read_only,
|
||||
"hidePasswords": entry.hide_passwords
|
||||
"hidePasswords": entry.hide_passwords,
|
||||
"manage": false
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -122,13 +127,6 @@ impl GroupUser {
|
||||
}
|
||||
}
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
use super::{User, UserOrganization};
|
||||
|
||||
/// Database methods
|
||||
impl Group {
|
||||
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
|
||||
@@ -193,10 +191,11 @@ impl Group {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
groups::table
|
||||
.filter(groups::uuid.eq(uuid))
|
||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||
.first::<GroupDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
|
@@ -12,12 +12,13 @@ mod org_policy;
|
||||
mod organization;
|
||||
mod send;
|
||||
mod two_factor;
|
||||
mod two_factor_duo_context;
|
||||
mod two_factor_incomplete;
|
||||
mod user;
|
||||
|
||||
pub use self::attachment::Attachment;
|
||||
pub use self::auth_request::AuthRequest;
|
||||
pub use self::cipher::Cipher;
|
||||
pub use self::cipher::{Cipher, RepromptType};
|
||||
pub use self::collection::{Collection, CollectionCipher, CollectionUser};
|
||||
pub use self::device::{Device, DeviceType};
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType};
|
||||
@@ -29,5 +30,6 @@ pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType};
|
||||
pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization};
|
||||
pub use self::send::{Send, SendType};
|
||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||
pub use self::user::{Invitation, User, UserKdfType, UserStampException};
|
||||
|
@@ -142,16 +142,6 @@ impl OrgPolicy {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
org_policies::table
|
||||
.filter(org_policies::uuid.eq(uuid))
|
||||
.first::<OrgPolicyDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
org_policies::table
|
||||
@@ -342,9 +332,11 @@ impl OrgPolicy {
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn is_enabled_by_org(org_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> bool {
|
||||
if let Some(policy) = OrgPolicy::find_by_org_and_type(org_uuid, policy_type, conn).await {
|
||||
return policy.enabled;
|
||||
pub async fn is_enabled_for_member(org_user_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> bool {
|
||||
if let Some(membership) = UserOrganization::find_by_uuid(org_user_uuid, conn).await {
|
||||
if let Some(policy) = OrgPolicy::find_by_org_and_type(&membership.org_uuid, policy_type, conn).await {
|
||||
return policy.enabled;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
@@ -1,9 +1,13 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use num_traits::FromPrimitive;
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
|
||||
use super::{CollectionUser, Group, GroupUser, OrgPolicy, OrgPolicyType, TwoFactor, User};
|
||||
use crate::db::models::{Collection, CollectionGroup};
|
||||
use crate::CONFIG;
|
||||
|
||||
db_object! {
|
||||
@@ -112,7 +116,7 @@ impl PartialOrd<i32> for UserOrgType {
|
||||
}
|
||||
|
||||
fn ge(&self, other: &i32) -> bool {
|
||||
matches!(self.partial_cmp(other), Some(Ordering::Greater) | Some(Ordering::Equal))
|
||||
matches!(self.partial_cmp(other), Some(Ordering::Greater | Ordering::Equal))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +139,7 @@ impl PartialOrd<UserOrgType> for i32 {
|
||||
}
|
||||
|
||||
fn le(&self, other: &UserOrgType) -> bool {
|
||||
matches!(self.partial_cmp(other), Some(Ordering::Less) | Some(Ordering::Equal) | None)
|
||||
matches!(self.partial_cmp(other), Some(Ordering::Less | Ordering::Equal) | None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +160,11 @@ impl Organization {
|
||||
"id": self.uuid,
|
||||
"identifier": null, // not supported by us
|
||||
"name": self.name,
|
||||
"seats": 10, // The value doesn't matter, we don't check server-side
|
||||
// "maxAutoscaleSeats": null, // The value doesn't matter, we don't check server-side
|
||||
"maxCollections": 10, // The value doesn't matter, we don't check server-side
|
||||
"maxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
||||
"seats": null,
|
||||
"maxCollections": null,
|
||||
"maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side
|
||||
"use2fa": true,
|
||||
"useCustomPermissions": false,
|
||||
"useDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
|
||||
"useEvents": CONFIG.org_events_enabled(),
|
||||
"useGroups": CONFIG.org_groups_enabled(),
|
||||
@@ -182,8 +186,7 @@ impl Organization {
|
||||
"businessTaxNumber": null,
|
||||
|
||||
"billingEmail": self.billing_email,
|
||||
"plan": "TeamsAnnually",
|
||||
"planType": 5, // TeamsAnnually plan
|
||||
"planType": 6, // Custom plan
|
||||
"usersGetPremium": true,
|
||||
"object": "organization",
|
||||
})
|
||||
@@ -229,6 +232,14 @@ impl UserOrganization {
|
||||
false
|
||||
}
|
||||
|
||||
/// Return the status of the user in an unrevoked state
|
||||
pub fn get_unrevoked_status(&self) -> i32 {
|
||||
if self.status <= UserOrgStatus::Revoked as i32 {
|
||||
return self.status + ACTIVATE_REVOKE_DIFF;
|
||||
}
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn set_external_id(&mut self, external_id: Option<String>) -> bool {
|
||||
//Check if external id is empty. We don't want to have
|
||||
//empty strings in the database
|
||||
@@ -369,8 +380,8 @@ impl UserOrganization {
|
||||
"id": self.org_uuid,
|
||||
"identifier": null, // Not supported
|
||||
"name": org.name,
|
||||
"seats": 10, // The value doesn't matter, we don't check server-side
|
||||
"maxCollections": 10, // The value doesn't matter, we don't check server-side
|
||||
"seats": null,
|
||||
"maxCollections": null,
|
||||
"usersGetPremium": true,
|
||||
"use2fa": true,
|
||||
"useDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
|
||||
@@ -392,25 +403,27 @@ impl UserOrganization {
|
||||
"useCustomPermissions": false,
|
||||
"useActivateAutofillPolicy": false,
|
||||
|
||||
"organizationUserId": self.uuid,
|
||||
"providerId": null,
|
||||
"providerName": null,
|
||||
"providerType": null,
|
||||
"familySponsorshipFriendlyName": null,
|
||||
"familySponsorshipAvailable": false,
|
||||
"planProductType": 0,
|
||||
"planProductType": 3,
|
||||
"productTierType": 3, // Enterprise tier
|
||||
"keyConnectorEnabled": false,
|
||||
"keyConnectorUrl": null,
|
||||
"familySponsorshipLastSyncDate": null,
|
||||
"familySponsorshipValidUntil": null,
|
||||
"familySponsorshipToDelete": null,
|
||||
"accessSecretsManager": false,
|
||||
"limitCollectionCreationDeletion": true,
|
||||
"limitCollectionCreationDeletion": false, // This should be set to true only when we can handle roles like createNewCollections
|
||||
"allowAdminAccessToAllCollectionItems": true,
|
||||
"flexibleCollections": false,
|
||||
|
||||
"permissions": permissions,
|
||||
|
||||
"maxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
||||
"maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side
|
||||
|
||||
// These are per user
|
||||
"userId": self.user_uuid,
|
||||
@@ -449,28 +462,86 @@ impl UserOrganization {
|
||||
Vec::with_capacity(0)
|
||||
};
|
||||
|
||||
let collections: Vec<Value> = if include_collections {
|
||||
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||
// Check if a user is in a group which has access to all collections
|
||||
// If that is the case, we should not return individual collections!
|
||||
let full_access_group =
|
||||
CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await;
|
||||
|
||||
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
|
||||
let collections: Vec<Value> = if include_collections && !(full_access_group || self.has_full_access()) {
|
||||
// Get all collections for the user here already to prevent more queries
|
||||
let cu: HashMap<String, CollectionUser> =
|
||||
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|cu| (cu.collection_uuid.clone(), cu))
|
||||
.collect();
|
||||
|
||||
// Get all collection groups for this user to prevent there inclusion
|
||||
let cg: HashSet<String> = CollectionGroup::find_by_user(&self.user_uuid, conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|cu| {
|
||||
json!({
|
||||
"id": cu.collection_uuid,
|
||||
"readOnly": cu.read_only,
|
||||
"hidePasswords": cu.hide_passwords,
|
||||
})
|
||||
.into_iter()
|
||||
.map(|cg| cg.collections_uuid)
|
||||
.collect();
|
||||
|
||||
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let (read_only, hide_passwords, can_manage) = if self.has_full_access() {
|
||||
(false, false, self.atype >= UserOrgType::Manager)
|
||||
} else if let Some(cu) = cu.get(&c.uuid) {
|
||||
(
|
||||
cu.read_only,
|
||||
cu.hide_passwords,
|
||||
self.atype == UserOrgType::Manager && !cu.read_only && !cu.hide_passwords,
|
||||
)
|
||||
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
|
||||
// Those are returned via a special group endpoint
|
||||
} else if cg.contains(&c.uuid) {
|
||||
return None;
|
||||
} else {
|
||||
(true, true, false)
|
||||
};
|
||||
|
||||
Some(json!({
|
||||
"id": c.uuid,
|
||||
"readOnly": read_only,
|
||||
"hidePasswords": hide_passwords,
|
||||
"manage": can_manage,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::with_capacity(0)
|
||||
};
|
||||
|
||||
let permissions = json!({
|
||||
// TODO: Add support for Custom User Roles
|
||||
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
|
||||
"accessEventLogs": false,
|
||||
"accessImportExport": false,
|
||||
"accessReports": false,
|
||||
"createNewCollections": false,
|
||||
"editAnyCollection": false,
|
||||
"deleteAnyCollection": false,
|
||||
"editAssignedCollections": false,
|
||||
"deleteAssignedCollections": false,
|
||||
"manageGroups": false,
|
||||
"managePolicies": false,
|
||||
"manageSso": false, // Not supported
|
||||
"manageUsers": false,
|
||||
"manageResetPassword": false,
|
||||
"manageScim": false // Not supported (Not AGPLv3 Licensed)
|
||||
});
|
||||
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
"userId": self.user_uuid,
|
||||
"name": user.name,
|
||||
"name": if self.get_unrevoked_status() >= UserOrgStatus::Accepted as i32 { Some(user.name) } else { None },
|
||||
"email": user.email,
|
||||
"externalId": self.external_id,
|
||||
"avatarColor": user.avatar_color,
|
||||
"groups": groups,
|
||||
"collections": collections,
|
||||
|
||||
@@ -479,6 +550,13 @@ impl UserOrganization {
|
||||
"accessAll": self.access_all,
|
||||
"twoFactorEnabled": twofactor_enabled,
|
||||
"resetPasswordEnrolled": self.reset_password_key.is_some(),
|
||||
"hasMasterPassword": !user.password_hash.is_empty(),
|
||||
|
||||
"permissions": permissions,
|
||||
|
||||
"ssoBound": false, // Not supported
|
||||
"usesKeyConnector": false, // Not supported
|
||||
"accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
|
||||
|
||||
"object": "organizationUserUserDetails",
|
||||
})
|
||||
@@ -592,7 +670,7 @@ impl UserOrganization {
|
||||
}
|
||||
|
||||
pub async fn find_by_email_and_org(email: &str, org_id: &str, conn: &mut DbConn) -> Option<UserOrganization> {
|
||||
if let Some(user) = super::User::find_by_mail(email, conn).await {
|
||||
if let Some(user) = User::find_by_mail(email, conn).await {
|
||||
if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, org_id, conn).await {
|
||||
return Some(user_org);
|
||||
}
|
||||
@@ -734,6 +812,19 @@ impl UserOrganization {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_confirmed_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
)
|
||||
.first::<UserOrganizationDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
users_organizations::table
|
||||
|
@@ -268,9 +268,8 @@ impl Send {
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use uuid::Uuid;
|
||||
|
||||
let uuid_vec = match BASE64URL_NOPAD.decode(access_id.as_bytes()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return None,
|
||||
let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let uuid = match Uuid::from_slice(&uuid_vec) {
|
||||
@@ -291,6 +290,17 @@ impl Send {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
sends::table
|
||||
.filter(sends::uuid.eq(uuid))
|
||||
.filter(sends::user_uuid.eq(user_uuid))
|
||||
.first::<SendDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
sends::table
|
||||
|
84
src/db/models/two_factor_duo_context.rs
Normal file
84
src/db/models/two_factor_duo_context.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = twofactor_duo_ctx)]
|
||||
#[diesel(primary_key(state))]
|
||||
pub struct TwoFactorDuoContext {
|
||||
pub state: String,
|
||||
pub user_email: String,
|
||||
pub nonce: String,
|
||||
pub exp: i64,
|
||||
}
|
||||
}
|
||||
|
||||
impl TwoFactorDuoContext {
|
||||
pub async fn find_by_state(state: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {
|
||||
conn: {
|
||||
twofactor_duo_ctx::table
|
||||
.filter(twofactor_duo_ctx::state.eq(state))
|
||||
.first::<TwoFactorDuoContextDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &mut DbConn) -> EmptyResult {
|
||||
// A saved context should never be changed, only created or deleted.
|
||||
let exists = Self::find_by_state(state, conn).await;
|
||||
if exists.is_some() {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let exp = Utc::now().timestamp() + ttl;
|
||||
|
||||
db_run! {
|
||||
conn: {
|
||||
diesel::insert_into(twofactor_duo_ctx::table)
|
||||
.values((
|
||||
twofactor_duo_ctx::state.eq(state),
|
||||
twofactor_duo_ctx::user_email.eq(user_email),
|
||||
twofactor_duo_ctx::nonce.eq(nonce),
|
||||
twofactor_duo_ctx::exp.eq(exp)
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error saving context to twofactor_duo_ctx")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_expired(conn: &mut DbConn) -> Vec<Self> {
|
||||
let now = Utc::now().timestamp();
|
||||
db_run! {
|
||||
conn: {
|
||||
twofactor_duo_ctx::table
|
||||
.filter(twofactor_duo_ctx::exp.lt(now))
|
||||
.load::<TwoFactorDuoContextDb>(conn)
|
||||
.expect("Error finding expired contexts in twofactor_duo_ctx")
|
||||
.from_db()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! {
|
||||
conn: {
|
||||
diesel::delete(
|
||||
twofactor_duo_ctx::table
|
||||
.filter(twofactor_duo_ctx::state.eq(&self.state)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting from twofactor_duo_ctx")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn purge_expired_duo_contexts(conn: &mut DbConn) {
|
||||
for context in Self::find_expired(conn).await {
|
||||
context.delete(conn).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
@@ -13,6 +13,7 @@ db_object! {
|
||||
// must complete 2FA login before being added into the devices table.
|
||||
pub device_uuid: String,
|
||||
pub device_name: String,
|
||||
pub device_type: i32,
|
||||
pub login_time: NaiveDateTime,
|
||||
pub ip_address: String,
|
||||
}
|
||||
@@ -23,6 +24,7 @@ impl TwoFactorIncomplete {
|
||||
user_uuid: &str,
|
||||
device_uuid: &str,
|
||||
device_name: &str,
|
||||
device_type: i32,
|
||||
ip: &ClientIp,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
@@ -44,6 +46,7 @@ impl TwoFactorIncomplete {
|
||||
twofactor_incomplete::user_uuid.eq(user_uuid),
|
||||
twofactor_incomplete::device_uuid.eq(device_uuid),
|
||||
twofactor_incomplete::device_name.eq(device_name),
|
||||
twofactor_incomplete::device_type.eq(device_type),
|
||||
twofactor_incomplete::login_time.eq(Utc::now().naive_utc()),
|
||||
twofactor_incomplete::ip_address.eq(ip.ip.to_string()),
|
||||
))
|
||||
|
@@ -1,3 +1,4 @@
|
||||
use crate::util::{format_date, get_uuid, retry};
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -90,7 +91,7 @@ impl User {
|
||||
let email = email.to_lowercase();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: get_uuid(),
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -107,7 +108,7 @@ impl User {
|
||||
salt: crypto::get_random_bytes::<64>().to_vec(),
|
||||
password_iterations: CONFIG.password_iterations(),
|
||||
|
||||
security_stamp: crate::util::get_uuid(),
|
||||
security_stamp: get_uuid(),
|
||||
stamp_exception: None,
|
||||
|
||||
password_hint: None,
|
||||
@@ -144,14 +145,14 @@ impl User {
|
||||
|
||||
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
|
||||
if let Some(ref totp_recover) = self.totp_recover {
|
||||
crate::crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
|
||||
crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_valid_api_key(&self, key: &str) -> bool {
|
||||
matches!(self.api_key, Some(ref api_key) if crate::crypto::ct_eq(api_key, key))
|
||||
matches!(self.api_key, Some(ref api_key) if crypto::ct_eq(api_key, key))
|
||||
}
|
||||
|
||||
/// Set the password hash generated
|
||||
@@ -188,7 +189,7 @@ impl User {
|
||||
}
|
||||
|
||||
pub fn reset_security_stamp(&mut self) {
|
||||
self.security_stamp = crate::util::get_uuid();
|
||||
self.security_stamp = get_uuid();
|
||||
}
|
||||
|
||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||
@@ -259,6 +260,7 @@ impl User {
|
||||
"forcePasswordReset": false,
|
||||
"avatarColor": self.avatar_color,
|
||||
"usesKeyConnector": false,
|
||||
"creationDate": format_date(&self.created_at),
|
||||
"object": "profile",
|
||||
})
|
||||
}
|
||||
@@ -340,7 +342,7 @@ impl User {
|
||||
let updated_at = Utc::now().naive_utc();
|
||||
|
||||
db_run! {conn: {
|
||||
crate::util::retry(|| {
|
||||
retry(|| {
|
||||
diesel::update(users::table)
|
||||
.set(users::updated_at.eq(updated_at))
|
||||
.execute(conn)
|
||||
@@ -357,7 +359,7 @@ impl User {
|
||||
|
||||
async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! {conn: {
|
||||
crate::util::retry(|| {
|
||||
retry(|| {
|
||||
diesel::update(users::table.filter(users::uuid.eq(uuid)))
|
||||
.set(users::updated_at.eq(date))
|
||||
.execute(conn)
|
||||
|
@@ -169,11 +169,21 @@ table! {
|
||||
user_uuid -> Text,
|
||||
device_uuid -> Text,
|
||||
device_name -> Text,
|
||||
device_type -> Integer,
|
||||
login_time -> Timestamp,
|
||||
ip_address -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@@ -169,11 +169,21 @@ table! {
|
||||
user_uuid -> Text,
|
||||
device_uuid -> Text,
|
||||
device_name -> Text,
|
||||
device_type -> Integer,
|
||||
login_time -> Timestamp,
|
||||
ip_address -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@@ -169,11 +169,21 @@ table! {
|
||||
user_uuid -> Text,
|
||||
device_uuid -> Text,
|
||||
device_name -> Text,
|
||||
device_type -> Integer,
|
||||
login_time -> Timestamp,
|
||||
ip_address -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@@ -2,6 +2,7 @@
|
||||
// Error generator macro
|
||||
//
|
||||
use crate::db::models::EventType;
|
||||
use crate::http_client::CustomHttpClientError;
|
||||
use std::error::Error as StdError;
|
||||
|
||||
macro_rules! make_error {
|
||||
@@ -68,6 +69,10 @@ make_error! {
|
||||
Empty(Empty): _no_source, _serialize,
|
||||
// Used to represent err! calls
|
||||
Simple(String): _no_source, _api_error,
|
||||
|
||||
// Used in our custom http client to handle non-global IPs and blocked domains
|
||||
CustomHttpClient(CustomHttpClientError): _has_source, _api_error,
|
||||
|
||||
// Used for special return values, like 2FA errors
|
||||
Json(Value): _no_source, _serialize,
|
||||
Db(DieselErr): _has_source, _api_error,
|
||||
@@ -204,7 +209,7 @@ use rocket::http::{ContentType, Status};
|
||||
use rocket::request::Request;
|
||||
use rocket::response::{self, Responder, Response};
|
||||
|
||||
impl<'r> Responder<'r, 'static> for Error {
|
||||
impl Responder<'_, 'static> for Error {
|
||||
fn respond_to(self, _: &Request<'_>) -> response::Result<'static> {
|
||||
match self.error {
|
||||
ErrorKind::Empty(_) => {} // Don't print the error in this situation
|
||||
|
246
src/http_client.rs
Normal file
246
src/http_client.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use std::{
|
||||
fmt,
|
||||
net::{IpAddr, SocketAddr},
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hickory_resolver::{system_conf::read_system_conf, TokioAsyncResolver};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use reqwest::{
|
||||
dns::{Name, Resolve, Resolving},
|
||||
header, Client, ClientBuilder,
|
||||
};
|
||||
use url::Host;
|
||||
|
||||
use crate::{util::is_global, CONFIG};
|
||||
|
||||
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
|
||||
let Ok(url) = url::Url::parse(url) else {
|
||||
err!("Invalid URL");
|
||||
};
|
||||
let Some(host) = url.host() else {
|
||||
err!("Invalid host");
|
||||
};
|
||||
|
||||
should_block_host(host)?;
|
||||
|
||||
static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client"));
|
||||
|
||||
Ok(INSTANCE.request(method, url))
|
||||
}
|
||||
|
||||
pub fn get_reqwest_client_builder() -> ClientBuilder {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
|
||||
|
||||
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() >= 5 {
|
||||
return attempt.error("Too many redirects");
|
||||
}
|
||||
|
||||
let Some(host) = attempt.url().host() else {
|
||||
return attempt.error("Invalid host");
|
||||
};
|
||||
|
||||
if let Err(e) = should_block_host(host) {
|
||||
return attempt.error(e);
|
||||
}
|
||||
|
||||
attempt.follow()
|
||||
});
|
||||
|
||||
Client::builder()
|
||||
.default_headers(headers)
|
||||
.redirect(redirect_policy)
|
||||
.dns_resolver(CustomDnsResolver::instance())
|
||||
.timeout(Duration::from_secs(10))
|
||||
}
|
||||
|
||||
pub fn should_block_address(domain_or_ip: &str) -> bool {
|
||||
if let Ok(ip) = IpAddr::from_str(domain_or_ip) {
|
||||
if should_block_ip(ip) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
should_block_address_regex(domain_or_ip)
|
||||
}
|
||||
|
||||
fn should_block_ip(ip: IpAddr) -> bool {
|
||||
if !CONFIG.http_request_block_non_global_ips() {
|
||||
return false;
|
||||
}
|
||||
|
||||
!is_global(ip)
|
||||
}
|
||||
|
||||
fn should_block_address_regex(domain_or_ip: &str) -> bool {
|
||||
let Some(block_regex) = CONFIG.http_request_block_regex() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
static COMPILED_REGEX: Mutex<Option<(String, Regex)>> = Mutex::new(None);
|
||||
let mut guard = COMPILED_REGEX.lock().unwrap();
|
||||
|
||||
// If the stored regex is up to date, use it
|
||||
if let Some((value, regex)) = &*guard {
|
||||
if value == &block_regex {
|
||||
return regex.is_match(domain_or_ip);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a regex stored, or it's not up to date, recreate it
|
||||
let regex = Regex::new(&block_regex).unwrap();
|
||||
let is_match = regex.is_match(domain_or_ip);
|
||||
*guard = Some((block_regex, regex));
|
||||
|
||||
is_match
|
||||
}
|
||||
|
||||
fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> {
|
||||
let (ip, host_str): (Option<IpAddr>, String) = match host {
|
||||
Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()),
|
||||
Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()),
|
||||
Host::Domain(d) => (None, d.to_string()),
|
||||
};
|
||||
|
||||
if let Some(ip) = ip {
|
||||
if should_block_ip(ip) {
|
||||
return Err(CustomHttpClientError::NonGlobalIp {
|
||||
domain: None,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if should_block_address_regex(&host_str) {
|
||||
return Err(CustomHttpClientError::Blocked {
|
||||
domain: host_str,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CustomHttpClientError {
|
||||
Blocked {
|
||||
domain: String,
|
||||
},
|
||||
NonGlobalIp {
|
||||
domain: Option<String>,
|
||||
ip: IpAddr,
|
||||
},
|
||||
}
|
||||
|
||||
impl CustomHttpClientError {
|
||||
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
|
||||
let mut source = e.source();
|
||||
|
||||
while let Some(err) = source {
|
||||
source = err.source();
|
||||
if let Some(err) = err.downcast_ref::<CustomHttpClientError>() {
|
||||
return Some(err);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CustomHttpClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Blocked {
|
||||
domain,
|
||||
} => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"),
|
||||
Self::NonGlobalIp {
|
||||
domain: Some(domain),
|
||||
ip,
|
||||
} => write!(f, "IP {ip} for domain '{domain}' is not a global IP!"),
|
||||
Self::NonGlobalIp {
|
||||
domain: None,
|
||||
ip,
|
||||
} => write!(f, "IP {ip} is not a global IP!"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CustomHttpClientError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum CustomDnsResolver {
|
||||
Default(),
|
||||
Hickory(Arc<TokioAsyncResolver>),
|
||||
}
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
impl CustomDnsResolver {
|
||||
fn instance() -> Arc<Self> {
|
||||
static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new);
|
||||
Arc::clone(&*INSTANCE)
|
||||
}
|
||||
|
||||
fn new() -> Arc<Self> {
|
||||
match read_system_conf() {
|
||||
Ok((config, opts)) => {
|
||||
let resolver = TokioAsyncResolver::tokio(config.clone(), opts.clone());
|
||||
Arc::new(Self::Hickory(Arc::new(resolver)))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error creating Hickory resolver, falling back to default: {e:?}");
|
||||
Arc::new(Self::Default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note that we get an iterator of addresses, but we only grab the first one for convenience
|
||||
async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> {
|
||||
pre_resolve(name)?;
|
||||
|
||||
let result = match self {
|
||||
Self::Default() => tokio::net::lookup_host(name).await?.next(),
|
||||
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)),
|
||||
};
|
||||
|
||||
if let Some(addr) = &result {
|
||||
post_resolve(name, addr.ip())?;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> {
|
||||
if should_block_address(name) {
|
||||
return Err(CustomHttpClientError::Blocked {
|
||||
domain: name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
|
||||
if should_block_ip(ip) {
|
||||
Err(CustomHttpClientError::NonGlobalIp {
|
||||
domain: Some(name.to_string()),
|
||||
ip,
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for CustomDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let this = self.clone();
|
||||
Box::pin(async move {
|
||||
let name = name.as_str();
|
||||
let result = this.resolve_domain(name).await?;
|
||||
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter()))
|
||||
})
|
||||
}
|
||||
}
|
102
src/mail.rs
102
src/mail.rs
@@ -17,6 +17,7 @@ use crate::{
|
||||
encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims,
|
||||
generate_verify_email_claims,
|
||||
},
|
||||
db::models::{Device, DeviceType, User},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
@@ -95,7 +96,31 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
||||
smtp_client.build()
|
||||
}
|
||||
|
||||
// This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections
|
||||
fn sanitize_data(data: &mut serde_json::Value) {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||
|
||||
match data {
|
||||
serde_json::Value::String(s) => *s = RE.replace_all(s, "").to_string(),
|
||||
serde_json::Value::Object(obj) => {
|
||||
for d in obj.values_mut() {
|
||||
sanitize_data(d);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for d in arr.iter_mut() {
|
||||
sanitize_data(d);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> {
|
||||
let mut data = data;
|
||||
sanitize_data(&mut data);
|
||||
let (subject_html, body_html) = get_template(&format!("{template_name}.html"), &data)?;
|
||||
let (_subject_text, body_text) = get_template(template_name, &data)?;
|
||||
Ok((subject_html, body_html, body_text))
|
||||
@@ -115,6 +140,10 @@ fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String
|
||||
None => err!("Template doesn't contain body"),
|
||||
};
|
||||
|
||||
if text_split.next().is_some() {
|
||||
err!("Template contains more than one body");
|
||||
}
|
||||
|
||||
Ok((subject, body))
|
||||
}
|
||||
|
||||
@@ -229,37 +258,49 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) ->
|
||||
}
|
||||
|
||||
pub async fn send_invite(
|
||||
address: &str,
|
||||
uuid: &str,
|
||||
user: &User,
|
||||
org_id: Option<String>,
|
||||
org_user_id: Option<String>,
|
||||
org_name: &str,
|
||||
invited_by_email: Option<String>,
|
||||
) -> EmptyResult {
|
||||
let claims = generate_invite_claims(
|
||||
uuid.to_string(),
|
||||
String::from(address),
|
||||
user.uuid.clone(),
|
||||
user.email.clone(),
|
||||
org_id.clone(),
|
||||
org_user_id.clone(),
|
||||
invited_by_email,
|
||||
);
|
||||
let invite_token = encode_jwt(&claims);
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
{
|
||||
let mut query_params = query.query_pairs_mut();
|
||||
query_params
|
||||
.append_pair("email", &user.email)
|
||||
.append_pair("organizationName", org_name)
|
||||
.append_pair("organizationId", org_id.as_deref().unwrap_or("_"))
|
||||
.append_pair("organizationUserId", org_user_id.as_deref().unwrap_or("_"))
|
||||
.append_pair("token", &invite_token);
|
||||
if user.private_key.is_some() {
|
||||
query_params.append_pair("orgUserHasExistingUser", "true");
|
||||
}
|
||||
}
|
||||
|
||||
let Some(query_string) = query.query() else {
|
||||
err!("Failed to build invite URL query parameters")
|
||||
};
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/send_org_invite",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
// `url.Url` would place the anchor `#` after the query parameters
|
||||
"url": format!("{}/#/accept-organization/?{}", CONFIG.domain(), query_string),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"org_id": org_id.as_deref().unwrap_or("_"),
|
||||
"org_user_id": org_user_id.as_deref().unwrap_or("_"),
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"org_name_encoded": percent_encode(org_name.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"org_name": org_name,
|
||||
"token": invite_token,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
send_email(&user.email, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_emergency_access_invite(
|
||||
@@ -277,17 +318,28 @@ pub async fn send_emergency_access_invite(
|
||||
String::from(grantor_email),
|
||||
);
|
||||
|
||||
let invite_token = encode_jwt(&claims);
|
||||
// Build the query here to ensure proper escaping
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
{
|
||||
let mut query_params = query.query_pairs_mut();
|
||||
query_params
|
||||
.append_pair("id", emer_id)
|
||||
.append_pair("name", grantor_name)
|
||||
.append_pair("email", address)
|
||||
.append_pair("token", &encode_jwt(&claims));
|
||||
}
|
||||
|
||||
let Some(query_string) = query.query() else {
|
||||
err!("Failed to build emergency invite URL query parameters")
|
||||
};
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/send_emergency_access_invite",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
// `url.Url` would place the anchor `#` after the query parameters
|
||||
"url": format!("{}/#/accept-emergency/?{query_string}", CONFIG.domain()),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"emer_id": emer_id,
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"grantor_name": grantor_name,
|
||||
"token": invite_token,
|
||||
}),
|
||||
)?;
|
||||
|
||||
@@ -427,9 +479,8 @@ pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult {
|
||||
pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &Device) -> EmptyResult {
|
||||
use crate::util::upcase_first;
|
||||
let device = upcase_first(device);
|
||||
|
||||
let fmt = "%A, %B %_d, %Y at %r %Z";
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -438,7 +489,8 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"ip": ip,
|
||||
"device": device,
|
||||
"device_name": upcase_first(&device.name),
|
||||
"device_type": DeviceType::from_i32(device.atype).to_string(),
|
||||
"datetime": crate::util::format_naive_datetime_local(dt, fmt),
|
||||
}),
|
||||
)?;
|
||||
@@ -446,9 +498,14 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult {
|
||||
pub async fn send_incomplete_2fa_login(
|
||||
address: &str,
|
||||
ip: &str,
|
||||
dt: &NaiveDateTime,
|
||||
device_name: &str,
|
||||
device_type: &str,
|
||||
) -> EmptyResult {
|
||||
use crate::util::upcase_first;
|
||||
let device = upcase_first(device);
|
||||
|
||||
let fmt = "%A, %B %_d, %Y at %r %Z";
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -457,7 +514,8 @@ pub async fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTi
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"ip": ip,
|
||||
"device": device,
|
||||
"device_name": upcase_first(device_name),
|
||||
"device_type": device_type,
|
||||
"datetime": crate::util::format_naive_datetime_local(dt, fmt),
|
||||
"time_limit": CONFIG.incomplete_2fa_time_limit(),
|
||||
}),
|
||||
|
233
src/main.rs
233
src/main.rs
@@ -26,6 +26,7 @@ extern crate diesel;
|
||||
extern crate diesel_migrations;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{canonicalize, create_dir_all},
|
||||
panic,
|
||||
path::Path,
|
||||
@@ -39,6 +40,9 @@ use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::SignalKind;
|
||||
|
||||
#[macro_use]
|
||||
mod error;
|
||||
mod api;
|
||||
@@ -47,36 +51,30 @@ mod config;
|
||||
mod crypto;
|
||||
#[macro_use]
|
||||
mod db;
|
||||
mod http_client;
|
||||
mod mail;
|
||||
mod ratelimit;
|
||||
mod util;
|
||||
|
||||
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
|
||||
use crate::api::purge_auth_requests;
|
||||
use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS};
|
||||
pub use config::CONFIG;
|
||||
pub use error::{Error, MapResult};
|
||||
use rocket::data::{Limits, ToByteUnit};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{atomic::Ordering, Arc};
|
||||
pub use util::is_running_in_container;
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
parse_args();
|
||||
parse_args().await;
|
||||
launch_info();
|
||||
|
||||
use log::LevelFilter as LF;
|
||||
let level = LF::from_str(&CONFIG.log_level()).unwrap_or_else(|_| {
|
||||
let valid_log_levels = LF::iter().map(|lvl| lvl.as_str().to_lowercase()).collect::<Vec<String>>().join(", ");
|
||||
println!("Log level must be one of the following: {valid_log_levels}");
|
||||
exit(1);
|
||||
});
|
||||
init_logging(level).ok();
|
||||
|
||||
let extra_debug = matches!(level, LF::Trace | LF::Debug);
|
||||
let level = init_logging()?;
|
||||
|
||||
check_data_folder().await;
|
||||
auth::initialize_keys().unwrap_or_else(|_| {
|
||||
error!("Error creating keys, exiting...");
|
||||
auth::initialize_keys().unwrap_or_else(|e| {
|
||||
error!("Error creating private key '{}'\n{e:?}\nExiting Vaultwarden!", CONFIG.private_rsa_key());
|
||||
exit(1);
|
||||
});
|
||||
check_web_vault();
|
||||
@@ -88,8 +86,9 @@ async fn main() -> Result<(), Error> {
|
||||
|
||||
let pool = create_db_pool().await;
|
||||
schedule_jobs(pool.clone());
|
||||
crate::db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap();
|
||||
db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap();
|
||||
|
||||
let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug);
|
||||
launch_rocket(pool, extra_debug).await // Blocks until program termination.
|
||||
}
|
||||
|
||||
@@ -101,10 +100,12 @@ USAGE:
|
||||
|
||||
FLAGS:
|
||||
-h, --help Prints help information
|
||||
-v, --version Prints the app version
|
||||
-v, --version Prints the app and web-vault version
|
||||
|
||||
COMMAND:
|
||||
hash [--preset {bitwarden|owasp}] Generate an Argon2id PHC ADMIN_TOKEN
|
||||
backup Create a backup of the SQLite database
|
||||
You can also send the USR1 signal to trigger a backup
|
||||
|
||||
PRESETS: m= t= p=
|
||||
bitwarden (default) 64MiB, 3 Iterations, 4 Threads
|
||||
@@ -114,16 +115,19 @@ PRESETS: m= t= p=
|
||||
|
||||
pub const VERSION: Option<&str> = option_env!("VW_VERSION");
|
||||
|
||||
fn parse_args() {
|
||||
async fn parse_args() {
|
||||
let mut pargs = pico_args::Arguments::from_env();
|
||||
let version = VERSION.unwrap_or("(Version info from Git not present)");
|
||||
|
||||
if pargs.contains(["-h", "--help"]) {
|
||||
println!("vaultwarden {version}");
|
||||
println!("Vaultwarden {version}");
|
||||
print!("{HELP}");
|
||||
exit(0);
|
||||
} else if pargs.contains(["-v", "--version"]) {
|
||||
println!("vaultwarden {version}");
|
||||
config::SKIP_CONFIG_VALIDATION.store(true, Ordering::Relaxed);
|
||||
let web_vault_version = util::get_web_vault_version();
|
||||
println!("Vaultwarden {version}");
|
||||
println!("Web-Vault {web_vault_version}");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@@ -167,7 +171,7 @@ fn parse_args() {
|
||||
}
|
||||
|
||||
let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
|
||||
let salt = SaltString::encode_b64(&crate::crypto::get_random_bytes::<32>()).unwrap();
|
||||
let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap();
|
||||
|
||||
let argon2_timer = tokio::time::Instant::now();
|
||||
if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) {
|
||||
@@ -178,13 +182,42 @@ fn parse_args() {
|
||||
argon2_timer.elapsed()
|
||||
);
|
||||
} else {
|
||||
error!("Unable to generate Argon2id PHC hash.");
|
||||
println!("Unable to generate Argon2id PHC hash.");
|
||||
exit(1);
|
||||
}
|
||||
} else if command == "backup" {
|
||||
match backup_sqlite().await {
|
||||
Ok(f) => {
|
||||
println!("Backup to '{f}' was successful");
|
||||
exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Backup failed. {e:?}");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn backup_sqlite() -> Result<String, Error> {
|
||||
use crate::db::{backup_database, DbConnType};
|
||||
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false) {
|
||||
// Establish a connection to the sqlite database
|
||||
let mut conn = db::DbPool::from_config()
|
||||
.expect("SQLite database connection failed")
|
||||
.get()
|
||||
.await
|
||||
.expect("Unable to get SQLite db pool");
|
||||
|
||||
let backup_file = backup_database(&mut conn).await?;
|
||||
Ok(backup_file)
|
||||
} else {
|
||||
err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_info() {
|
||||
println!(
|
||||
"\
|
||||
@@ -210,7 +243,38 @@ fn launch_info() {
|
||||
);
|
||||
}
|
||||
|
||||
fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
fn init_logging() -> Result<log::LevelFilter, Error> {
|
||||
let levels = log::LevelFilter::iter().map(|lvl| lvl.as_str().to_lowercase()).collect::<Vec<String>>().join("|");
|
||||
let log_level_rgx_str = format!("^({levels})((,[^,=]+=({levels}))*)$");
|
||||
let log_level_rgx = regex::Regex::new(&log_level_rgx_str)?;
|
||||
let config_str = CONFIG.log_level().to_lowercase();
|
||||
|
||||
let (level, levels_override) = if let Some(caps) = log_level_rgx.captures(&config_str) {
|
||||
let level = caps
|
||||
.get(1)
|
||||
.and_then(|m| log::LevelFilter::from_str(m.as_str()).ok())
|
||||
.ok_or(Error::new("Failed to parse global log level".to_string(), ""))?;
|
||||
|
||||
let levels_override: Vec<(&str, log::LevelFilter)> = caps
|
||||
.get(2)
|
||||
.map(|m| {
|
||||
m.as_str()
|
||||
.split(',')
|
||||
.collect::<Vec<&str>>()
|
||||
.into_iter()
|
||||
.flat_map(|s| match s.split('=').collect::<Vec<&str>>()[..] {
|
||||
[log, lvl_str] => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.ok_or(Error::new("Failed to parse overrides".to_string(), ""))?;
|
||||
|
||||
(level, levels_override)
|
||||
} else {
|
||||
err!(format!("LOG_LEVEL should follow the format info,vaultwarden::api::icons=debug, invalid: {config_str}"))
|
||||
};
|
||||
|
||||
// Depending on the main log level we either want to disable or enable logging for hickory.
|
||||
// Else if there are timeouts it will clutter the logs since hickory uses warn for this.
|
||||
let hickory_level = if level >= log::LevelFilter::Debug {
|
||||
@@ -241,47 +305,61 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
log::LevelFilter::Warn
|
||||
};
|
||||
|
||||
let mut logger = fern::Dispatch::new()
|
||||
.level(level)
|
||||
// Hide unknown certificate errors if using self-signed
|
||||
.level_for("rustls::session", log::LevelFilter::Off)
|
||||
// Hide failed to close stream messages
|
||||
.level_for("hyper::server", log::LevelFilter::Warn)
|
||||
// Silence Rocket `_` logs
|
||||
.level_for("_", rocket_underscore_level)
|
||||
.level_for("rocket::response::responder::_", rocket_underscore_level)
|
||||
.level_for("rocket::server::_", rocket_underscore_level)
|
||||
.level_for("vaultwarden::api::admin::_", rocket_underscore_level)
|
||||
.level_for("vaultwarden::api::notifications::_", rocket_underscore_level)
|
||||
// Silence Rocket logs
|
||||
.level_for("rocket::launch", log::LevelFilter::Error)
|
||||
.level_for("rocket::launch_", log::LevelFilter::Error)
|
||||
.level_for("rocket::rocket", log::LevelFilter::Warn)
|
||||
.level_for("rocket::server", log::LevelFilter::Warn)
|
||||
.level_for("rocket::fairing::fairings", log::LevelFilter::Warn)
|
||||
.level_for("rocket::shield::shield", log::LevelFilter::Warn)
|
||||
.level_for("hyper::proto", log::LevelFilter::Off)
|
||||
.level_for("hyper::client", log::LevelFilter::Off)
|
||||
// Filter handlebars logs
|
||||
.level_for("handlebars::render", handlebars_level)
|
||||
// Prevent cookie_store logs
|
||||
.level_for("cookie_store", log::LevelFilter::Off)
|
||||
// Variable level for hickory used by reqwest
|
||||
.level_for("hickory_resolver::name_server::name_server", hickory_level)
|
||||
.level_for("hickory_proto::xfer", hickory_level)
|
||||
.level_for("diesel_logger", diesel_logger_level)
|
||||
.chain(std::io::stdout());
|
||||
|
||||
// Enable smtp debug logging only specifically for smtp when need.
|
||||
// This can contain sensitive information we do not want in the default debug/trace logging.
|
||||
if CONFIG.smtp_debug() {
|
||||
let smtp_log_level = if CONFIG.smtp_debug() {
|
||||
log::LevelFilter::Debug
|
||||
} else {
|
||||
log::LevelFilter::Off
|
||||
};
|
||||
|
||||
let mut default_levels = HashMap::from([
|
||||
// Hide unknown certificate errors if using self-signed
|
||||
("rustls::session", log::LevelFilter::Off),
|
||||
// Hide failed to close stream messages
|
||||
("hyper::server", log::LevelFilter::Warn),
|
||||
// Silence Rocket `_` logs
|
||||
("_", rocket_underscore_level),
|
||||
("rocket::response::responder::_", rocket_underscore_level),
|
||||
("rocket::server::_", rocket_underscore_level),
|
||||
("vaultwarden::api::admin::_", rocket_underscore_level),
|
||||
("vaultwarden::api::notifications::_", rocket_underscore_level),
|
||||
// Silence Rocket logs
|
||||
("rocket::launch", log::LevelFilter::Error),
|
||||
("rocket::launch_", log::LevelFilter::Error),
|
||||
("rocket::rocket", log::LevelFilter::Warn),
|
||||
("rocket::server", log::LevelFilter::Warn),
|
||||
("rocket::fairing::fairings", log::LevelFilter::Warn),
|
||||
("rocket::shield::shield", log::LevelFilter::Warn),
|
||||
("hyper::proto", log::LevelFilter::Off),
|
||||
("hyper::client", log::LevelFilter::Off),
|
||||
// Filter handlebars logs
|
||||
("handlebars::render", handlebars_level),
|
||||
// Prevent cookie_store logs
|
||||
("cookie_store", log::LevelFilter::Off),
|
||||
// Variable level for hickory used by reqwest
|
||||
("hickory_resolver::name_server::name_server", hickory_level),
|
||||
("hickory_proto::xfer", hickory_level),
|
||||
("diesel_logger", diesel_logger_level),
|
||||
// SMTP
|
||||
("lettre::transport::smtp", smtp_log_level),
|
||||
]);
|
||||
|
||||
for (path, level) in levels_override.into_iter() {
|
||||
let _ = default_levels.insert(path, level);
|
||||
}
|
||||
|
||||
if Some(&log::LevelFilter::Debug) == default_levels.get("lettre::transport::smtp") {
|
||||
println!(
|
||||
"[WARNING] SMTP Debugging is enabled (SMTP_DEBUG=true). Sensitive information could be disclosed via logs!\n\
|
||||
[WARNING] Only enable SMTP_DEBUG during troubleshooting!\n"
|
||||
);
|
||||
logger = logger.level_for("lettre::transport::smtp", log::LevelFilter::Debug)
|
||||
} else {
|
||||
logger = logger.level_for("lettre::transport::smtp", log::LevelFilter::Off)
|
||||
}
|
||||
|
||||
let mut logger = fern::Dispatch::new().level(level).chain(std::io::stdout());
|
||||
|
||||
for (path, level) in default_levels {
|
||||
logger = logger.level_for(path.to_string(), level);
|
||||
}
|
||||
|
||||
if CONFIG.extended_logging() {
|
||||
@@ -303,22 +381,24 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
{
|
||||
logger = logger.chain(fern::log_file(log_file)?);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(unix)]
|
||||
{
|
||||
const SIGHUP: i32 = tokio::signal::unix::SignalKind::hangup().as_raw_value();
|
||||
const SIGHUP: i32 = SignalKind::hangup().as_raw_value();
|
||||
let path = Path::new(&log_file);
|
||||
logger = logger.chain(fern::log_reopen1(path, [SIGHUP])?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if cfg!(feature = "enable_syslog") || CONFIG.use_syslog() {
|
||||
logger = chain_syslog(logger);
|
||||
}
|
||||
}
|
||||
|
||||
logger.apply()?;
|
||||
if let Err(err) = logger.apply() {
|
||||
err!(format!("Failed to activate logger: {err}"))
|
||||
}
|
||||
|
||||
// Catch panics and log them instead of default output to StdErr
|
||||
panic::set_hook(Box::new(|info| {
|
||||
@@ -356,10 +436,10 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
|
||||
}
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
Ok(level)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(unix)]
|
||||
fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch {
|
||||
let syslog_fmt = syslog::Formatter3164 {
|
||||
facility: syslog::Facility::LOG_USER,
|
||||
@@ -431,10 +511,10 @@ async fn container_data_folder_is_persistent(data_folder: &str) -> bool {
|
||||
format!(" /{data_folder} ")
|
||||
};
|
||||
let mut lines = BufReader::new(mountinfo).lines();
|
||||
let re = regex::Regex::new(r"/volumes/[a-z0-9]{64}/_data /").unwrap();
|
||||
while let Some(line) = lines.next_line().await.unwrap_or_default() {
|
||||
// Only execute a regex check if we find the base match
|
||||
if line.contains(&data_folder_match) {
|
||||
let re = regex::Regex::new(r"/volumes/[a-z0-9]{64}/_data /").unwrap();
|
||||
if re.is_match(&line) {
|
||||
return false;
|
||||
}
|
||||
@@ -513,11 +593,27 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
|
||||
info!("Exiting vaultwarden!");
|
||||
info!("Exiting Vaultwarden!");
|
||||
CONFIG.shutdown();
|
||||
});
|
||||
|
||||
let _ = instance.launch().await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap();
|
||||
loop {
|
||||
// If we need more signals to act upon, we might want to use select! here.
|
||||
// With only one item to listen for this is enough.
|
||||
let _ = signal_user1.recv().await;
|
||||
match backup_sqlite().await {
|
||||
Ok(f) => info!("Backup to '{f}' was successful"),
|
||||
Err(e) => error!("Backup failed. {e:?}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
instance.launch().await?;
|
||||
|
||||
info!("Vaultwarden process exited!");
|
||||
Ok(())
|
||||
@@ -584,6 +680,13 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||
}));
|
||||
}
|
||||
|
||||
// Clean unused, expired Duo authentication contexts.
|
||||
if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() {
|
||||
sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || {
|
||||
runtime.spawn(purge_duo_contexts(pool.clone()));
|
||||
}));
|
||||
}
|
||||
|
||||
// Cleanup the event table of records x days old.
|
||||
if CONFIG.org_events_enabled()
|
||||
&& !CONFIG.event_cleanup_schedule().is_empty()
|
||||
|
4
src/static/scripts/admin.css
vendored
4
src/static/scripts/admin.css
vendored
@@ -38,8 +38,8 @@ img {
|
||||
max-width: 130px;
|
||||
}
|
||||
#users-table .vw-actions, #orgs-table .vw-actions {
|
||||
min-width: 130px;
|
||||
max-width: 130px;
|
||||
min-width: 135px;
|
||||
max-width: 140px;
|
||||
}
|
||||
#users-table .vw-org-cell {
|
||||
max-height: 120px;
|
||||
|
8
src/static/scripts/admin.js
vendored
8
src/static/scripts/admin.js
vendored
@@ -49,8 +49,8 @@ function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
}).then(respText => {
|
||||
try {
|
||||
const respJson = JSON.parse(respText);
|
||||
if (respJson.ErrorModel && respJson.ErrorModel.Message) {
|
||||
return respJson.ErrorModel.Message;
|
||||
if (respJson.errorModel && respJson.errorModel.message) {
|
||||
return respJson.errorModel.message;
|
||||
} else {
|
||||
return Promise.reject({ body: `${respStatus} - ${respStatusText}\n\nUnknown error`, error: true });
|
||||
}
|
||||
@@ -98,7 +98,7 @@ const showActiveTheme = (theme, focus = false) => {
|
||||
const themeSwitcherText = document.querySelector("#bd-theme-text");
|
||||
const activeThemeIcon = document.querySelector(".theme-icon-active use");
|
||||
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
||||
const svgOfActiveBtn = btnToActive.querySelector("span use").innerText;
|
||||
const svgOfActiveBtn = btnToActive.querySelector("span use").textContent;
|
||||
|
||||
document.querySelectorAll("[data-bs-theme-value]").forEach(element => {
|
||||
element.classList.remove("active");
|
||||
@@ -107,7 +107,7 @@ const showActiveTheme = (theme, focus = false) => {
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
activeThemeIcon.innerText = svgOfActiveBtn;
|
||||
activeThemeIcon.textContent = svgOfActiveBtn;
|
||||
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
|
||||
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
|
||||
|
||||
|
222
src/static/scripts/admin_diagnostics.js
vendored
222
src/static/scripts/admin_diagnostics.js
vendored
@@ -7,6 +7,8 @@ var timeCheck = false;
|
||||
var ntpTimeCheck = false;
|
||||
var domainCheck = false;
|
||||
var httpsCheck = false;
|
||||
var websocketCheck = false;
|
||||
var httpResponseCheck = false;
|
||||
|
||||
// ================================
|
||||
// Date & Time Check
|
||||
@@ -76,18 +78,15 @@ async function generateSupportString(event, dj) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let supportString = "### Your environment (Generated via diagnostics page)\n";
|
||||
let supportString = "### Your environment (Generated via diagnostics page)\n\n";
|
||||
|
||||
supportString += `* Vaultwarden version: v${dj.current_release}\n`;
|
||||
supportString += `* Web-vault version: v${dj.web_vault_version}\n`;
|
||||
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
|
||||
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`;
|
||||
supportString += "* Environment settings overridden: ";
|
||||
if (dj.overrides != "") {
|
||||
supportString += "true\n";
|
||||
} else {
|
||||
supportString += "false\n";
|
||||
}
|
||||
supportString += `* Database type: ${dj.db_type}\n`;
|
||||
supportString += `* Database version: ${dj.db_version}\n`;
|
||||
supportString += `* Environment settings overridden!: ${dj.overrides !== ""}\n`;
|
||||
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`;
|
||||
if (dj.ip_header_exists) {
|
||||
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`;
|
||||
@@ -99,11 +98,12 @@ async function generateSupportString(event, dj) {
|
||||
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`;
|
||||
supportString += `* Domain Configuration Check: ${domainCheck}\n`;
|
||||
supportString += `* HTTPS Check: ${httpsCheck}\n`;
|
||||
supportString += `* Database type: ${dj.db_type}\n`;
|
||||
supportString += `* Database version: ${dj.db_version}\n`;
|
||||
supportString += "* Clients used: \n";
|
||||
supportString += "* Reverse proxy and version: \n";
|
||||
supportString += "* Other relevant information: \n";
|
||||
if (dj.enable_websocket) {
|
||||
supportString += `* Websocket Check: ${websocketCheck}\n`;
|
||||
} else {
|
||||
supportString += "* Websocket Check: disabled\n";
|
||||
}
|
||||
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
|
||||
|
||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||
"headers": { "Accept": "application/json" }
|
||||
@@ -113,11 +113,31 @@ async function generateSupportString(event, dj) {
|
||||
throw new Error(jsonResponse);
|
||||
}
|
||||
const configJson = await jsonResponse.json();
|
||||
supportString += "\n### Config (Generated via diagnostics page)\n<details><summary>Show Running Config</summary>\n";
|
||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||
supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n</details>\n";
|
||||
|
||||
document.getElementById("support-string").innerText = supportString;
|
||||
// Start Config and Details section within a details block which is collapsed by default
|
||||
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
||||
supportString += "<details><summary>Show Config & Details</summary>\n";
|
||||
|
||||
// Add overrides if they exists
|
||||
if (dj.overrides != "") {
|
||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||
}
|
||||
|
||||
// Add http response check messages if they exists
|
||||
if (httpResponseCheck === false) {
|
||||
supportString += "\n**Failed HTTP Checks:**\n";
|
||||
// We use `innerText` here since that will convert <br> into new-lines
|
||||
supportString += "\n```yaml\n" + document.getElementById("http-response-errors").innerText.trim() + "\n```\n";
|
||||
}
|
||||
|
||||
// Add the current config in json form
|
||||
supportString += "\n**Config:**\n";
|
||||
supportString += "\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n";
|
||||
|
||||
supportString += "\n</details>\n";
|
||||
|
||||
// Add the support string to the textbox so it can be viewed and copied
|
||||
document.getElementById("support-string").textContent = supportString;
|
||||
document.getElementById("support-string").classList.remove("d-none");
|
||||
document.getElementById("copy-support").classList.remove("d-none");
|
||||
}
|
||||
@@ -126,7 +146,7 @@ function copyToClipboard(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const supportStr = document.getElementById("support-string").innerText;
|
||||
const supportStr = document.getElementById("support-string").textContent;
|
||||
const tmpCopyEl = document.createElement("textarea");
|
||||
|
||||
tmpCopyEl.setAttribute("id", "copy-support-string");
|
||||
@@ -199,9 +219,165 @@ function checkDns(dns_resolved) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCheckUrl(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return { headers: response.headers, status: response.status, text: await response.text() };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${url}: ${error}`);
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function checkSecurityHeaders(headers, omit) {
|
||||
let securityHeaders = {
|
||||
"x-frame-options": ["SAMEORIGIN"],
|
||||
"x-content-type-options": ["nosniff"],
|
||||
"referrer-policy": ["same-origin"],
|
||||
"x-xss-protection": ["0"],
|
||||
"x-robots-tag": ["noindex", "nofollow"],
|
||||
"content-security-policy": [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'self' blob:",
|
||||
"script-src 'self' 'wasm-unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"child-src 'self' https://*.duosecurity.com https://*.duofederal.com",
|
||||
"frame-src 'self' https://*.duosecurity.com https://*.duofederal.com",
|
||||
"frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh moz-extension://*",
|
||||
"img-src 'self' data: https://haveibeenpwned.com",
|
||||
"connect-src 'self' https://api.pwnedpasswords.com https://api.2fa.directory https://app.simplelogin.io/api/ https://app.addy.io/api/ https://api.fastmail.com/ https://api.forwardemail.net",
|
||||
]
|
||||
};
|
||||
|
||||
let messages = [];
|
||||
for (let header in securityHeaders) {
|
||||
// Skip some headers for specific endpoints if needed
|
||||
if (typeof omit === "object" && omit.includes(header) === true) {
|
||||
continue;
|
||||
}
|
||||
// If the header exists, check if the contents matches what we expect it to be
|
||||
let headerValue = headers.get(header);
|
||||
if (headerValue !== null) {
|
||||
securityHeaders[header].forEach((expectedValue) => {
|
||||
if (headerValue.indexOf(expectedValue) === -1) {
|
||||
messages.push(`'${header}' does not contain '${expectedValue}'`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
messages.push(`'${header}' is missing!`);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function checkHttpResponse() {
|
||||
const [apiConfig, webauthnConnector, notFound, notFoundApi, badRequest, unauthorized, forbidden] = await Promise.all([
|
||||
fetchCheckUrl(`${BASE_URL}/api/config`),
|
||||
fetchCheckUrl(`${BASE_URL}/webauthn-connector.html`),
|
||||
fetchCheckUrl(`${BASE_URL}/admin/does-not-exist`),
|
||||
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=404`),
|
||||
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=400`),
|
||||
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=401`),
|
||||
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=403`),
|
||||
]);
|
||||
|
||||
const respErrorElm = document.getElementById("http-response-errors");
|
||||
|
||||
// Check and validate the default API header responses
|
||||
let apiErrors = checkSecurityHeaders(apiConfig.headers);
|
||||
if (apiErrors.length >= 1) {
|
||||
respErrorElm.innerHTML += "<b>API calls:</b><br>";
|
||||
apiErrors.forEach((errMsg) => {
|
||||
respErrorElm.innerHTML += `<b>Header:</b> ${errMsg}<br>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Check the special `-connector.html` headers, these should have some headers omitted.
|
||||
const omitConnectorHeaders = ["x-frame-options", "content-security-policy"];
|
||||
let connectorErrors = checkSecurityHeaders(webauthnConnector.headers, omitConnectorHeaders);
|
||||
omitConnectorHeaders.forEach((header) => {
|
||||
if (webauthnConnector.headers.get(header) !== null) {
|
||||
connectorErrors.push(`'${header}' is present while it should not`);
|
||||
}
|
||||
});
|
||||
if (connectorErrors.length >= 1) {
|
||||
respErrorElm.innerHTML += "<b>2FA Connector calls:</b><br>";
|
||||
connectorErrors.forEach((errMsg) => {
|
||||
respErrorElm.innerHTML += `<b>Header:</b> ${errMsg}<br>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Check specific error code responses if they are not re-written by a reverse proxy
|
||||
let responseErrors = [];
|
||||
if (notFound.status !== 404 || notFound.text.indexOf("return to the web-vault") === -1) {
|
||||
responseErrors.push("404 (Not Found) HTML is invalid");
|
||||
}
|
||||
|
||||
if (notFoundApi.status !== 404 || notFoundApi.text.indexOf("\"message\":\"Testing error 404 response\",") === -1) {
|
||||
responseErrors.push("404 (Not Found) JSON is invalid");
|
||||
}
|
||||
|
||||
if (badRequest.status !== 400 || badRequest.text.indexOf("\"message\":\"Testing error 400 response\",") === -1) {
|
||||
responseErrors.push("400 (Bad Request) is invalid");
|
||||
}
|
||||
|
||||
if (unauthorized.status !== 401 || unauthorized.text.indexOf("\"message\":\"Testing error 401 response\",") === -1) {
|
||||
responseErrors.push("401 (Unauthorized) is invalid");
|
||||
}
|
||||
|
||||
if (forbidden.status !== 403 || forbidden.text.indexOf("\"message\":\"Testing error 403 response\",") === -1) {
|
||||
responseErrors.push("403 (Forbidden) is invalid");
|
||||
}
|
||||
|
||||
if (responseErrors.length >= 1) {
|
||||
respErrorElm.innerHTML += "<b>HTTP error responses:</b><br>";
|
||||
responseErrors.forEach((errMsg) => {
|
||||
respErrorElm.innerHTML += `<b>Response to:</b> ${errMsg}<br>`;
|
||||
});
|
||||
}
|
||||
|
||||
if (responseErrors.length >= 1 || connectorErrors.length >= 1 || apiErrors.length >= 1) {
|
||||
document.getElementById("http-response-warning").classList.remove("d-none");
|
||||
} else {
|
||||
httpResponseCheck = true;
|
||||
document.getElementById("http-response-success").classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWsUrl(wsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.onopen = () => {
|
||||
ws.close();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
reject(false);
|
||||
};
|
||||
} catch (_) {
|
||||
reject(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function checkWebsocketConnection() {
|
||||
// Test Websocket connections via the anonymous (login with device) connection
|
||||
const isConnected = await fetchWsUrl(`${BASE_URL}/notifications/anonymous-hub?token=admin-diagnostics`).catch(() => false);
|
||||
if (isConnected) {
|
||||
websocketCheck = true;
|
||||
document.getElementById("websocket-success").classList.remove("d-none");
|
||||
} else {
|
||||
document.getElementById("websocket-error").classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
function init(dj) {
|
||||
// Time check
|
||||
document.getElementById("time-browser-string").innerText = browserUTC;
|
||||
document.getElementById("time-browser-string").textContent = browserUTC;
|
||||
|
||||
// Check if we were able to fetch a valid NTP Time
|
||||
// If so, compare both browser and server with NTP
|
||||
@@ -217,7 +393,7 @@ function init(dj) {
|
||||
|
||||
// Domain check
|
||||
const browserURL = location.href.toLowerCase();
|
||||
document.getElementById("domain-browser-string").innerText = browserURL;
|
||||
document.getElementById("domain-browser-string").textContent = browserURL;
|
||||
checkDomain(browserURL, dj.admin_url.toLowerCase());
|
||||
|
||||
// Version check
|
||||
@@ -225,11 +401,17 @@ function init(dj) {
|
||||
|
||||
// DNS Check
|
||||
checkDns(dj.dns_resolved);
|
||||
|
||||
checkHttpResponse();
|
||||
|
||||
if (dj.enable_websocket) {
|
||||
checkWebsocketConnection();
|
||||
}
|
||||
}
|
||||
|
||||
// onLoad events
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
const diag_json = JSON.parse(document.getElementById("diagnostics_json").innerText);
|
||||
const diag_json = JSON.parse(document.getElementById("diagnostics_json").textContent);
|
||||
init(diag_json);
|
||||
|
||||
const btnGenSupport = document.getElementById("gen-support");
|
||||
|
2
src/static/scripts/admin_settings.js
vendored
2
src/static/scripts/admin_settings.js
vendored
@@ -122,7 +122,7 @@ function submitTestEmailOnEnter() {
|
||||
function colorRiskSettings() {
|
||||
const risk_items = document.getElementsByClassName("col-form-label");
|
||||
Array.from(risk_items).forEach((el) => {
|
||||
if (el.innerText.toLowerCase().includes("risks") ) {
|
||||
if (el.textContent.toLowerCase().includes("risks") ) {
|
||||
el.parentElement.className += " alert-danger";
|
||||
}
|
||||
});
|
||||
|
6
src/static/scripts/admin_users.js
vendored
6
src/static/scripts/admin_users.js
vendored
@@ -198,7 +198,8 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
|
||||
const orgName = event.relatedTarget.dataset.vwOrgName;
|
||||
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
|
||||
|
||||
document.getElementById("userOrgTypeDialogTitle").innerHTML = `<b>Update User Type:</b><br><b>Organization:</b> ${orgName}<br><b>User:</b> ${userEmail}`;
|
||||
document.getElementById("userOrgTypeDialogOrgName").textContent = orgName;
|
||||
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
|
||||
document.getElementById("userOrgTypeUserUuid").value = userUuid;
|
||||
document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
|
||||
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true;
|
||||
@@ -206,7 +207,8 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
|
||||
|
||||
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
|
||||
userOrgTypeDialog.addEventListener("hide.bs.modal", function() {
|
||||
document.getElementById("userOrgTypeDialogTitle").innerHTML = "";
|
||||
document.getElementById("userOrgTypeDialogOrgName").textContent = "";
|
||||
document.getElementById("userOrgTypeDialogUserEmail").textContent = "";
|
||||
document.getElementById("userOrgTypeUserUuid").value = "";
|
||||
document.getElementById("userOrgTypeOrgUuid").value = "";
|
||||
}, false);
|
||||
|
42
src/static/scripts/datatables.css
vendored
42
src/static/scripts/datatables.css
vendored
@@ -4,10 +4,10 @@
|
||||
*
|
||||
* To rebuild or modify this file with the latest versions of the included
|
||||
* software please visit:
|
||||
* https://datatables.net/download/#bs5/dt-2.0.7
|
||||
* https://datatables.net/download/#bs5/dt-2.1.8
|
||||
*
|
||||
* Included libraries:
|
||||
* DataTables 2.0.7
|
||||
* DataTables 2.1.8
|
||||
*/
|
||||
|
||||
@charset "UTF-8";
|
||||
@@ -45,15 +45,21 @@ table.dataTable tr.dt-hasChild td.dt-control:before {
|
||||
}
|
||||
|
||||
html.dark table.dataTable td.dt-control:before,
|
||||
:root[data-bs-theme=dark] table.dataTable td.dt-control:before {
|
||||
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
|
||||
:root[data-theme=dark] table.dataTable td.dt-control:before {
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
|
||||
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
|
||||
border-top-color: rgba(255, 255, 255, 0.5);
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
div.dt-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.dt-scroll-body thead tr,
|
||||
div.dt-scroll-body tfoot tr {
|
||||
height: 0;
|
||||
@@ -377,6 +383,31 @@ table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
|
||||
}
|
||||
|
||||
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
div.dt-container div.dt-layout-end > *:not(:first-child) {
|
||||
margin-left: 1em;
|
||||
}
|
||||
div.dt-container div.dt-layout-full {
|
||||
width: 100%;
|
||||
}
|
||||
div.dt-container div.dt-layout-full > *:only-child {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
div.dt-container div.dt-layout-table > div {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
div.dt-container div.dt-layout-end > *:not(:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
div.dt-container div.dt-length label {
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
@@ -400,9 +431,6 @@ div.dt-container div.dt-search input {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
div.dt-container div.dt-info {
|
||||
padding-top: 0.85em;
|
||||
}
|
||||
div.dt-container div.dt-paging {
|
||||
margin: 0;
|
||||
}
|
||||
|
1431
src/static/scripts/datatables.js
vendored
1431
src/static/scripts/datatables.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -132,6 +132,21 @@
|
||||
<span class="d-block" title="We have direct internet access, no outgoing proxy configured."><b>No</b></span>
|
||||
{{/unless}}
|
||||
</dd>
|
||||
<dt class="col-sm-5">Websocket enabled
|
||||
{{#if page_data.enable_websocket}}
|
||||
<span class="badge bg-success d-none" id="websocket-success" title="Websocket connection is working.">Ok</span>
|
||||
<span class="badge bg-danger d-none" id="websocket-error" title="Websocket connection error, validate your reverse proxy configuration!">Error</span>
|
||||
{{/if}}
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
{{#if page_data.enable_websocket}}
|
||||
<span class="d-block" title="Websocket connections are enabled (ENABLE_WEBSOCKET is true)."><b>Yes</b></span>
|
||||
{{/if}}
|
||||
{{#unless page_data.enable_websocket}}
|
||||
<span class="d-block" title="Websocket connections are disabled (ENABLE_WEBSOCKET is false)."><b>No</b></span>
|
||||
{{/unless}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">DNS (github.com)
|
||||
<span class="badge bg-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span>
|
||||
<span class="badge bg-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
|
||||
@@ -167,6 +182,14 @@
|
||||
<span id="domain-server" class="d-block"><b>Server:</b> <span id="domain-server-string">{{page_data.admin_url}}</span></span>
|
||||
<span id="domain-browser" class="d-block"><b>Browser:</b> <span id="domain-browser-string"></span></span>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">HTTP Response validation
|
||||
<span class="badge bg-success d-none" id="http-response-success" title="All headers and HTTP request responses seem to be ok.">Ok</span>
|
||||
<span class="badge bg-danger d-none" id="http-response-warning" title="Some headers or HTTP request responses return invalid data!">Error</span>
|
||||
</dt>
|
||||
<dd class="col-sm-7">
|
||||
<span id="http-response-errors" class="d-block"></span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -44,7 +44,7 @@
|
||||
<span class="d-block"><strong>Events:</strong> {{event_count}}</span>
|
||||
</td>
|
||||
<td class="text-end px-0 small">
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-organization data-vw-org-uuid="{{jsesc id no_quote}}" data-vw-org-name="{{jsesc name no_quote}}" data-vw-billing-email="{{jsesc billingEmail no_quote}}">Delete Organization</button><br>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-organization data-vw-org-uuid="{{id}}" data-vw-org-name="{{name}}" data-vw-billing-email="{{billingEmail}}">Delete Organization</button><br>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
@@ -19,7 +19,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<svg width="48" height="48" class="float-start me-2 rounded" data-jdenticon-value="{{email}}">
|
||||
<div class="float-start">
|
||||
<div>
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block">{{email}}</span>
|
||||
<span class="d-block">
|
||||
@@ -54,14 +54,14 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="overflow-auto vw-org-cell" data-vw-user-email="{{jsesc email no_quote}}" data-vw-user-uuid="{{jsesc id no_quote}}">
|
||||
<div class="overflow-auto vw-org-cell" data-vw-user-email="{{email}}" data-vw-user-uuid="{{id}}">
|
||||
{{#each organizations}}
|
||||
<button class="badge" data-bs-toggle="modal" data-bs-target="#userOrgTypeDialog" data-vw-org-type="{{type}}" data-vw-org-uuid="{{jsesc id no_quote}}" data-vw-org-name="{{jsesc name no_quote}}">{{name}}</button>
|
||||
<button class="badge" data-bs-toggle="modal" data-bs-target="#userOrgTypeDialog" data-vw-org-type="{{type}}" data-vw-org-uuid="{{id}}" data-vw-org-name="{{name}}">{{name}}</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end px-0 small">
|
||||
<span data-vw-user-uuid="{{jsesc id no_quote}}" data-vw-user-email="{{jsesc email no_quote}}">
|
||||
<span data-vw-user-uuid="{{id}}" data-vw-user-email="{{email}}">
|
||||
{{#if twoFactorEnabled}}
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-remove2fa>Remove all 2FA</button><br>
|
||||
{{/if}}
|
||||
@@ -109,7 +109,9 @@
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title" id="userOrgTypeDialogTitle"></h6>
|
||||
<h6 class="modal-title">
|
||||
<b>Update User Type:</b><br><b>Organization:</b> <span id="userOrgTypeDialogOrgName"></span><br><b>User:</b> <span id="userOrgTypeDialogUserEmail"></span>
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form class="form" id="userOrgTypeForm">
|
||||
|
@@ -7,7 +7,7 @@
|
||||
|
||||
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
|
||||
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 5px 0 20px 0;" valign="top">
|
||||
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
|
||||
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
|
||||
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top"><a href="https://github.com/dani-garcia/vaultwarden" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;"><img src="{{img_src}}mail-github.png" alt="GitHub" width="30" height="30" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;" /></a></td>
|
||||
|
@@ -1,10 +1,11 @@
|
||||
Incomplete Two-Step Login From {{{device}}}
|
||||
Incomplete Two-Step Login From {{{device_name}}}
|
||||
<!---------------->
|
||||
Someone attempted to log into your account with the correct master password, but did not provide the correct token or action required to complete the two-step login process within {{time_limit}} minutes of the initial login attempt.
|
||||
|
||||
* Date: {{datetime}}
|
||||
* IP Address: {{ip}}
|
||||
* Device Type: {{device}}
|
||||
* Device Name: {{device_name}}
|
||||
* Device Type: {{device_type}}
|
||||
|
||||
If this was not you or someone you authorized, then you should change your master password as soon as possible, as it is likely to be compromised.
|
||||
{{> email/email_footer_text }}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Incomplete Two-Step Login From {{{device}}}
|
||||
Incomplete Two-Step Login From {{{device_name}}}
|
||||
<!---------------->
|
||||
{{> email/email_header }}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
@@ -19,7 +19,12 @@ Incomplete Two-Step Login From {{{device}}}
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b>Device Type:</b> {{device}}
|
||||
<b>Device Name:</b> {{device_name}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b>Device Type:</b> {{device_type}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
|
@@ -1,10 +1,11 @@
|
||||
New Device Logged In From {{{device}}}
|
||||
New Device Logged In From {{{device_name}}}
|
||||
<!---------------->
|
||||
Your account was just logged into from a new device.
|
||||
|
||||
* Date: {{datetime}}
|
||||
* IP Address: {{ip}}
|
||||
* Device Type: {{device}}
|
||||
* Device Name: {{device_name}}
|
||||
* Device Type: {{device_type}}
|
||||
|
||||
You can deauthorize all devices that have access to your account from the web vault ( {{url}} ) under Settings > My Account > Deauthorize Sessions.
|
||||
{{> email/email_footer_text }}
|
@@ -1,4 +1,4 @@
|
||||
New Device Logged In From {{{device}}}
|
||||
New Device Logged In From {{{device_name}}}
|
||||
<!---------------->
|
||||
{{> email/email_header }}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
@@ -9,7 +9,7 @@ New Device Logged In From {{{device}}}
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b>Date</b>: {{datetime}}
|
||||
<b>Date:</b> {{datetime}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
@@ -19,7 +19,12 @@ New Device Logged In From {{{device}}}
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b>Device Type:</b> {{device}}
|
||||
<b>Device Name:</b> {{device_name}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
<b>Device Type:</b> {{device_type}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
|
@@ -2,7 +2,7 @@ Emergency access for {{{grantor_name}}}
|
||||
<!---------------->
|
||||
You have been invited to become an emergency contact for {{grantor_name}}. To accept this invite, click the following link:
|
||||
|
||||
Click here to join: {{url}}/#/accept-emergency/?id={{emer_id}}&name={{grantor_name}}&email={{email}}&token={{token}}
|
||||
Click here to join: {{{url}}}
|
||||
|
||||
If you do not wish to become an emergency contact for {{grantor_name}}, you can safely ignore this email.
|
||||
{{> email/email_footer_text }}
|
||||
|
@@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}}
|
||||
</tr>
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||
<a href="{{url}}/#/accept-emergency/?id={{emer_id}}&name={{grantor_name}}&email={{email}}&token={{token}}"
|
||||
<a href="{{{url}}}"
|
||||
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Become emergency contact
|
||||
</a>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user