mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-11-27 23:22:33 +02:00
Compare commits
152 Commits
1.31.0
...
0d16da440d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d16da440d | ||
|
|
66cf179bca | ||
|
|
025bb90f8f | ||
|
|
d5039d9c17 | ||
|
|
e7c796a660 | ||
|
|
bbbd2f6d15 | ||
|
|
a2d7895586 | ||
|
|
8a0cb1137e | ||
|
|
f960bf59bb | ||
|
|
3a1f1bae00 | ||
|
|
8dfe805954 | ||
|
|
07b869b3ef | ||
|
|
2a18665288 | ||
|
|
71952a4ab5 | ||
|
|
994d157064 | ||
|
|
1dae6093c9 | ||
|
|
6edceb5f7a | ||
|
|
359a4a088a | ||
|
|
3baffeee9a | ||
|
|
d5c353427d | ||
|
|
1f868b8d22 | ||
|
|
8d1df08b81 | ||
|
|
3b6bccde97 | ||
|
|
d2b36642a6 | ||
|
|
a02fb0fd24 | ||
|
|
1109293992 | ||
|
|
3c29f82974 | ||
|
|
663f88e717 | ||
|
|
a3dccee243 | ||
|
|
c0ebe0d982 | ||
|
|
1b46c80389 | ||
|
|
2c549984c0 | ||
|
|
ecab7a50ea | ||
|
|
2903a3a13a | ||
|
|
952992c85b | ||
|
|
c0be36a17f | ||
|
|
d1dee04615 | ||
|
|
ef2695de0c | ||
|
|
29f2b433f0 | ||
|
|
07f80346b4 | ||
|
|
4f68eafa3e | ||
|
|
327d369188 | ||
|
|
ca7483df85 | ||
|
|
16b6d2a71e | ||
|
|
871a3f214a | ||
|
|
10d12676cf | ||
|
|
dec3a9603a | ||
|
|
86aaf27659 | ||
|
|
bc913d1156 | ||
|
|
ef4bff09eb | ||
|
|
4816f77fd7 | ||
|
|
dfd9e65396 | ||
|
|
b1481c7c1a | ||
|
|
d9e0d68f20 | ||
|
|
08183fc999 | ||
|
|
d9b043d32c | ||
|
|
ed4ad67e73 | ||
|
|
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,16 @@
|
||||
# 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
|
||||
!macros
|
||||
!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 ###
|
||||
@@ -220,7 +229,8 @@
|
||||
# SIGNUPS_ALLOWED=true
|
||||
|
||||
## Controls if new users need to verify their email address upon registration
|
||||
## Note that setting this option to true prevents logins until the email address has been verified!
|
||||
## On new client versions, this will require the user to verify their email at signup time.
|
||||
## On older clients, it will require the user to verify their email before they can log in.
|
||||
## The welcome email will include a verification link, and login attempts will periodically
|
||||
## trigger another verification email to be sent.
|
||||
# SIGNUPS_VERIFY=false
|
||||
@@ -271,12 +281,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 +331,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 +349,14 @@
|
||||
## - "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.
|
||||
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension.
|
||||
## - "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)
|
||||
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs Android >=2025.2.0)
|
||||
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.2.0)
|
||||
## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.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 +380,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 +415,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 +436,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 +461,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
|
||||
@@ -445,7 +490,7 @@
|
||||
## Maximum attempts before an email token is reset and a new email will need to be sent.
|
||||
# EMAIL_ATTEMPTS_LIMIT=3
|
||||
##
|
||||
## Setup email 2FA regardless of any organization policy
|
||||
## Setup email 2FA on registration regardless of any organization policy
|
||||
# EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE=false
|
||||
## Automatically setup email 2FA as fallback provider when needed
|
||||
# EMAIL_2FA_AUTO_FALLBACK=false
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,3 +1,5 @@
|
||||
/.github @dani-garcia @BlackDex
|
||||
/.github/** @dani-garcia @BlackDex
|
||||
/.github/CODEOWNERS @dani-garcia @BlackDex
|
||||
/.github/workflows/** @dani-garcia @BlackDex
|
||||
/SECURITY.md @dani-garcia @BlackDex
|
||||
|
||||
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.
|
||||
|
||||
99
.github/workflows/build.yml
vendored
99
.github/workflows/build.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Build
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,6 +14,7 @@ on:
|
||||
- "diesel.toml"
|
||||
- "docker/Dockerfile.j2"
|
||||
- "docker/DockerSettings.yaml"
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/build.yml"
|
||||
@@ -28,12 +30,17 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test ${{ matrix.channel }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
# We use Ubuntu 22.04 here because this matches the library versions used within the Debian docker containers
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 120
|
||||
# Make warnings errors, this is to prevent warnings slipping through.
|
||||
# This is done globally to prevent rebuilds when the RUSTFLAGS env variable changes.
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,20 +48,19 @@ jobs:
|
||||
- "rust-toolchain" # The version defined in rust-toolchain
|
||||
- "msrv" # The supported MSRV
|
||||
|
||||
name: Build and Test ${{ matrix.channel }}
|
||||
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
|
||||
# End Checkout the repo
|
||||
|
||||
|
||||
# Install dependencies
|
||||
- name: "Install dependencies Ubuntu"
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config
|
||||
# End Install dependencies
|
||||
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# End Checkout the repo
|
||||
|
||||
# Determine rust-toolchain version
|
||||
- name: Init Variables
|
||||
@@ -74,7 +80,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@56f84321dbccf38fb67ce29ab63e4754056677e0 # master @ Mar 18, 2025, 8:14 PM GMT+1
|
||||
if: ${{ matrix.channel == 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
@@ -84,7 +90,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@56f84321dbccf38fb67ce29ab63e4754056677e0 # master @ Mar 18, 2025, 8:14 PM GMT+1
|
||||
if: ${{ matrix.channel != 'rust-toolchain' }}
|
||||
with:
|
||||
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
|
||||
@@ -92,11 +98,13 @@ jobs:
|
||||
|
||||
# Set the current matrix toolchain version as default
|
||||
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
|
||||
env:
|
||||
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||
run: |
|
||||
# Remove the rust-toolchain.toml
|
||||
rm rust-toolchain.toml
|
||||
# Set the default
|
||||
rustup default ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
|
||||
rustup default "${RUST_TOOLCHAIN}"
|
||||
|
||||
# Show environment
|
||||
- name: "Show environment"
|
||||
@@ -106,7 +114,8 @@ jobs:
|
||||
# End Show environment
|
||||
|
||||
# Enable Rust Caching
|
||||
- uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
|
||||
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,33 +125,39 @@ 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: ${{ !cancelled() }}
|
||||
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() }}
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
cargo test --features sqlite,mysql,postgresql,enable_mimalloc
|
||||
|
||||
- name: "test features: sqlite,mysql,postgresql"
|
||||
id: test_sqlite_mysql_postgresql
|
||||
if: $${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
cargo test --features sqlite,mysql,postgresql
|
||||
|
||||
- name: "test features: sqlite"
|
||||
id: test_sqlite
|
||||
if: $${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
cargo test --features sqlite
|
||||
|
||||
- name: "test features: mysql"
|
||||
id: test_mysql
|
||||
if: $${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
cargo test --features mysql
|
||||
|
||||
- name: "test features: postgresql"
|
||||
id: test_postgresql
|
||||
if: $${{ always() }}
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
cargo test --features postgresql
|
||||
# End Run cargo tests
|
||||
@@ -151,16 +166,16 @@ jobs:
|
||||
# Run cargo clippy, and fail on warnings
|
||||
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc"
|
||||
id: clippy
|
||||
if: ${{ always() && matrix.channel == 'rust-toolchain' }}
|
||||
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||
run: |
|
||||
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc -- -D warnings
|
||||
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc
|
||||
# End Run cargo clippy
|
||||
|
||||
|
||||
# Run cargo fmt (Only run on rust-toolchain defined version)
|
||||
- name: "check formatting"
|
||||
id: formatting
|
||||
if: ${{ always() && matrix.channel == 'rust-toolchain' }}
|
||||
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
# End Run cargo fmt
|
||||
@@ -170,21 +185,31 @@ jobs:
|
||||
# This is useful so all test/clippy/fmt actions are done, and they can all be addressed
|
||||
- name: "Some checks failed"
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
TEST_DB_M_L: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_logger.outcome }}
|
||||
TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }}
|
||||
TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }}
|
||||
TEST_SQLITE: ${{ steps.test_sqlite.outcome }}
|
||||
TEST_MYSQL: ${{ steps.test_mysql.outcome }}
|
||||
TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }}
|
||||
CLIPPY: ${{ steps.clippy.outcome }}
|
||||
FMT: ${{ steps.formatting.outcome }}
|
||||
run: |
|
||||
echo "### :x: Checks Failed!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|Job|Status|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---|------|" >> $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
|
||||
echo "|test (mysql)|${{ steps.test_mysql.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|test (postgresql)|${{ steps.test_postgresql.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${{ steps.clippy.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|fmt|${{ steps.formatting.outcome }}|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Please check the failed jobs and fix where needed." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc,query_logger)|${TEST_DB_M_L}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
|
||||
@@ -193,5 +218,5 @@ jobs:
|
||||
- name: "All checks passed"
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
echo "### :tada: Checks Passed!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### :tada: Checks Passed!" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
28
.github/workflows/check-templates.yml
vendored
Normal file
28
.github/workflows/check-templates.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Check templates
|
||||
permissions: {}
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
docker-templates:
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
- name: Run make to rebuild templates
|
||||
working-directory: docker
|
||||
run: make
|
||||
|
||||
- name: Check for unstaged changes
|
||||
working-directory: docker
|
||||
run: git diff --exit-code
|
||||
continue-on-error: false
|
||||
46
.github/workflows/hadolint.yml
vendored
46
.github/workflows/hadolint.yml
vendored
@@ -1,20 +1,28 @@
|
||||
name: Hadolint
|
||||
permissions: {}
|
||||
|
||||
on: [
|
||||
push,
|
||||
pull_request
|
||||
]
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
hadolint:
|
||||
name: Validate Dockerfile syntax
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
# End Checkout the repo
|
||||
# Start Docker Buildx
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
# https://github.com/moby/buildkit/issues/3969
|
||||
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 2
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
# Download hadolint - https://github.com/hadolint/hadolint/releases
|
||||
- name: Download hadolint
|
||||
@@ -25,9 +33,25 @@ jobs:
|
||||
env:
|
||||
HADOLINT_VERSION: 2.12.0
|
||||
# End Download hadolint
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# End Checkout the repo
|
||||
|
||||
# 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
|
||||
|
||||
198
.github/workflows/release.yml
vendored
198
.github/workflows/release.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Release
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,17 +7,23 @@ on:
|
||||
- main
|
||||
|
||||
tags:
|
||||
- '*'
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
|
||||
- '[1-2].[0-9]+.[0-9]+'
|
||||
|
||||
jobs:
|
||||
# https://github.com/marketplace/actions/skip-duplicate-actions
|
||||
# Some checks to determine if we need to continue with building a new docker.
|
||||
# We will skip this check if we are creating a tag, because that has the same hash as a previous run already.
|
||||
skip_check:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run this in the upstream repo and not on forks
|
||||
if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
|
||||
name: Cancel older jobs when running
|
||||
permissions:
|
||||
actions: write
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
|
||||
steps:
|
||||
- name: Skip Duplicates Actions
|
||||
id: skip_check
|
||||
@@ -27,11 +34,17 @@ jobs:
|
||||
if: ${{ github.ref_type == 'branch' }}
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 120
|
||||
needs: skip_check
|
||||
if: ${{ needs.skip_check.outputs.should_skip != 'true' && github.repository == 'dani-garcia/vaultwarden' }}
|
||||
# Start a local docker registry to extract the final Alpine static build binaries
|
||||
name: Build Vaultwarden containers
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
# Start a local docker registry to extract the compiled binaries to upload as artifacts and attest them
|
||||
services:
|
||||
registry:
|
||||
image: registry:2
|
||||
@@ -56,37 +69,42 @@ jobs:
|
||||
base_image: ["debian","alpine"]
|
||||
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize QEMU binfmt support
|
||||
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.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@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
# 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:
|
||||
cache-binary: false
|
||||
buildkitd-config-inline: |
|
||||
[worker.oci]
|
||||
max-parallelism = 3
|
||||
max-parallelism = 2
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
# We need fetch-depth of 0 so we also get all the tag metadata
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
# Determine Base Tags and Source Version
|
||||
- name: Determine Base Tags and Source Version
|
||||
shell: bash
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by github.ref_type
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
echo "BASE_TAGS=latest,${GITHUB_REF#refs/*/}" | tee -a "${GITHUB_ENV}"
|
||||
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
echo "BASE_TAGS=testing" | tee -a "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
@@ -102,7 +120,7 @@ jobs:
|
||||
|
||||
# Login to Docker Hub
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -111,12 +129,14 @@ jobs:
|
||||
- name: Add registry for DockerHub
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${{ vars.DOCKERHUB_REPO }}" | tee -a "${GITHUB_ENV}"
|
||||
echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Login to GitHub Container Registry
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -126,12 +146,14 @@ jobs:
|
||||
- name: Add registry for ghcr.io
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${{ vars.GHCR_REPO }}" | tee -a "${GITHUB_ENV}"
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Login to Quay.io
|
||||
- name: Login to Quay.io
|
||||
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
@@ -141,17 +163,22 @@ jobs:
|
||||
- name: Add registry for Quay.io
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
QUAY_REPO: ${{ vars.QUAY_REPO }}
|
||||
run: |
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${{ vars.QUAY_REPO }}" | tee -a "${GITHUB_ENV}"
|
||||
echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
- name: Configure build cache from/to
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_REPO: ${{ vars.GHCR_REPO }}
|
||||
BASE_IMAGE: ${{ matrix.base_image }}
|
||||
run: |
|
||||
#
|
||||
# Check if there is a GitHub Container Registry Login and use it for caching
|
||||
if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then
|
||||
echo "BAKE_CACHE_FROM=type=registry,ref=${{ vars.GHCR_REPO }}-buildcache:${{ matrix.base_image }}" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_TO=type=registry,ref=${{ vars.GHCR_REPO }}-buildcache:${{ matrix.base_image }},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}" | tee -a "${GITHUB_ENV}"
|
||||
echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
|
||||
else
|
||||
echo "BAKE_CACHE_FROM="
|
||||
echo "BAKE_CACHE_TO="
|
||||
@@ -159,13 +186,13 @@ jobs:
|
||||
#
|
||||
|
||||
- name: Add localhost registry
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
id: bake_vw
|
||||
uses: docker/bake-action@4ba453fbc2db7735392b93edf935aaf9b1e8f747 # v6.5.0
|
||||
env:
|
||||
BASE_TAGS: "${{ env.BASE_TAGS }}"
|
||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||
@@ -175,78 +202,119 @@ jobs:
|
||||
with:
|
||||
pull: true
|
||||
push: true
|
||||
source: .
|
||||
files: docker/docker-bake.hcl
|
||||
targets: "${{ matrix.base_image }}-multi"
|
||||
set: |
|
||||
*.cache-from=${{ env.BAKE_CACHE_FROM }}
|
||||
*.cache-to=${{ env.BAKE_CACHE_TO }}
|
||||
|
||||
- name: Extract digest SHA
|
||||
shell: bash
|
||||
env:
|
||||
BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }}
|
||||
run: |
|
||||
GET_DIGEST_SHA="$(jq -r '.["${{ matrix.base_image }}-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")"
|
||||
echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}"
|
||||
|
||||
# Attest container images
|
||||
- name: Attest - docker.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
|
||||
with:
|
||||
subject-name: ${{ vars.DOCKERHUB_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - ghcr.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
|
||||
with:
|
||||
subject-name: ${{ vars.GHCR_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Attest - quay.io - ${{ matrix.base_image }}
|
||||
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && steps.bake_vw.outputs.metadata != ''}}
|
||||
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
|
||||
with:
|
||||
subject-name: ${{ vars.QUAY_REPO }}
|
||||
subject-digest: ${{ env.DIGEST_SHA }}
|
||||
push-to-registry: true
|
||||
|
||||
|
||||
# Extract the Alpine binaries from the containers
|
||||
- name: Extract binaries
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
shell: bash
|
||||
env:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Check which main tag we are going to build determined by github.ref_type
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
# Check which main tag we are going to build determined by ref_type
|
||||
if [[ "${REF_TYPE}" == "tag" ]]; then
|
||||
EXTRACT_TAG="latest"
|
||||
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
|
||||
elif [[ "${REF_TYPE}" == "branch" ]]; then
|
||||
EXTRACT_TAG="testing"
|
||||
fi
|
||||
|
||||
# Check which base_image was used and append -alpine if needed
|
||||
if [[ "${{ matrix.base_image }}" == "alpine" ]]; then
|
||||
EXTRACT_TAG="${EXTRACT_TAG}-alpine"
|
||||
fi
|
||||
|
||||
# After each extraction the image is removed.
|
||||
# This is needed because using different platforms doesn't trigger a new pull/download
|
||||
|
||||
# Extract amd64 binary
|
||||
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker cp amd64:/vaultwarden vaultwarden-amd64
|
||||
docker create --name amd64 --platform=linux/amd64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp amd64:/vaultwarden vaultwarden-amd64-${{ matrix.base_image }}
|
||||
docker rm --force amd64
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract arm64 binary
|
||||
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker cp arm64:/vaultwarden vaultwarden-arm64
|
||||
docker create --name arm64 --platform=linux/arm64 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp arm64:/vaultwarden vaultwarden-arm64-${{ matrix.base_image }}
|
||||
docker rm --force arm64
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract armv7 binary
|
||||
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker cp armv7:/vaultwarden vaultwarden-armv7
|
||||
docker create --name armv7 --platform=linux/arm/v7 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp armv7:/vaultwarden vaultwarden-armv7-${{ matrix.base_image }}
|
||||
docker rm --force armv7
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Extract armv6 binary
|
||||
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker cp armv6:/vaultwarden vaultwarden-armv6
|
||||
docker create --name armv6 --platform=linux/arm/v6 "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
docker cp armv6:/vaultwarden vaultwarden-armv6-${{ matrix.base_image }}
|
||||
docker rm --force armv6
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}-alpine"
|
||||
docker rmi --force "localhost:5000/vaultwarden/server:${EXTRACT_TAG}"
|
||||
|
||||
# Upload artifacts to Github Actions
|
||||
- name: "Upload amd64 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
# Upload artifacts to Github Actions and Attest the binaries
|
||||
- name: "Upload amd64 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64
|
||||
path: vaultwarden-amd64
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-amd64-${{ matrix.base_image }}
|
||||
path: vaultwarden-amd64-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload arm64 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
- name: "Upload arm64 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64
|
||||
path: vaultwarden-arm64
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-arm64-${{ matrix.base_image }}
|
||||
path: vaultwarden-arm64-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload armv7 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
- name: "Upload armv7 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7
|
||||
path: vaultwarden-armv7
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv7-${{ matrix.base_image }}
|
||||
path: vaultwarden-armv7-${{ matrix.base_image }}
|
||||
|
||||
- name: "Upload armv6 artifact"
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
if: ${{ matrix.base_image == 'alpine' }}
|
||||
- name: "Upload armv6 artifact ${{ matrix.base_image }}"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6
|
||||
path: vaultwarden-armv6
|
||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-armv6-${{ matrix.base_image }}
|
||||
path: vaultwarden-armv6-${{ matrix.base_image }}
|
||||
|
||||
- name: "Attest artifacts ${{ matrix.base_image }}"
|
||||
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
|
||||
with:
|
||||
subject-path: vaultwarden-*
|
||||
# End Upload artifacts to Github Actions
|
||||
|
||||
8
.github/workflows/releasecache-cleanup.yml
vendored
8
.github/workflows/releasecache-cleanup.yml
vendored
@@ -1,3 +1,6 @@
|
||||
name: Cleanup
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -9,11 +12,12 @@ on:
|
||||
schedule:
|
||||
- cron: '0 1 * * FRI'
|
||||
|
||||
name: Cleanup
|
||||
jobs:
|
||||
releasecache-cleanup:
|
||||
name: Releasecache Cleanup
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
runs-on: ubuntu-24.04
|
||||
continue-on-error: true
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
39
.github/workflows/trivy.yml
vendored
39
.github/workflows/trivy.yml
vendored
@@ -1,34 +1,45 @@
|
||||
name: trivy
|
||||
name: Trivy
|
||||
permissions: {}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '00 12 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
schedule:
|
||||
- cron: '08 11 * * *'
|
||||
|
||||
jobs:
|
||||
trivy-scan:
|
||||
name: Check
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
# Only run this in the upstream 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: Trivy Scan
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
security-events: write
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@7c2007bcb556501da015201bcba5aa14069b74e2 # v0.23.0
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.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 +48,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: v5.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
@@ -31,7 +31,7 @@ repos:
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain|.*\.rs$)
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
@@ -40,5 +40,13 @@ repos:
|
||||
language: system
|
||||
args: ["--features", "sqlite,mysql,postgresql,enable_mimalloc", "--", "-D", "warnings"]
|
||||
types_or: [rust, file]
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain|clippy.toml|.*\.rs$)
|
||||
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
|
||||
pass_filenames: false
|
||||
- id: check-docker-templates
|
||||
name: check docker templates
|
||||
description: Check if the Docker templates are updated
|
||||
language: system
|
||||
entry: sh
|
||||
args:
|
||||
- "-c"
|
||||
- "cd docker && make"
|
||||
|
||||
2453
Cargo.lock
generated
2453
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
147
Cargo.toml
147
Cargo.toml
@@ -1,9 +1,12 @@
|
||||
[workspace]
|
||||
members = ["macros"]
|
||||
|
||||
[package]
|
||||
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.84.0"
|
||||
resolver = "2"
|
||||
|
||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||
@@ -18,42 +21,44 @@ 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]
|
||||
macros = { path = "./macros" }
|
||||
|
||||
# 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
|
||||
log = "0.4.27"
|
||||
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.21.3"
|
||||
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.19"
|
||||
num-derive = "0.4.2"
|
||||
bigdecimal = "0.4.5"
|
||||
bigdecimal = "0.4.8"
|
||||
|
||||
# Web framework
|
||||
rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false }
|
||||
@@ -63,99 +68,104 @@ 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.44.2", 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.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "2.2.1", features = ["chrono", "r2d2", "numeric"] }
|
||||
diesel = { version = "2.2.9", 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 }
|
||||
|
||||
derive_more = { version = "2.0.1", features = ["from", "into", "as_ref", "deref", "display"] }
|
||||
diesel-derive-newtype = "2.1.2"
|
||||
|
||||
# Bundled/Static SQLite
|
||||
libsqlite3-sys = { version = "0.28.0", features = ["bundled"], optional = true }
|
||||
libsqlite3-sys = { version = "0.32.0", features = ["bundled"], optional = true }
|
||||
|
||||
# Crypto-related libraries
|
||||
rand = { version = "0.8.5", features = ["small_rng"] }
|
||||
ring = "0.17.8"
|
||||
rand = "0.9.0"
|
||||
ring = "0.17.14"
|
||||
subtle = "2.6.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.9.1", features = ["v4"] }
|
||||
uuid = { version = "1.16.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.40", features = ["clock", "serde"], default-features = false }
|
||||
chrono-tz = "0.10.3"
|
||||
time = "0.3.41"
|
||||
|
||||
# Job scheduler
|
||||
job_scheduler_ng = "2.0.5"
|
||||
|
||||
# Data encoding library Hex/Base32/Base64
|
||||
data-encoding = "2.6.0"
|
||||
data-encoding = "2.8.0"
|
||||
|
||||
# JWT library
|
||||
jsonwebtoken = "9.3.0"
|
||||
jsonwebtoken = "9.3.1"
|
||||
|
||||
# TOTP library
|
||||
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.15", 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.3.2", 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.15", features = ["native-tls-alpn", "stream", "json", "gzip", "brotli", "socks", "cookies"] }
|
||||
hickory-resolver = "0.25.1"
|
||||
|
||||
# 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.10.1"
|
||||
|
||||
# Cache function results (Used for version check and favicon fetching)
|
||||
cached = { version = "0.52.0", features = ["async"] }
|
||||
cached = { version = "0.55.1", 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.72"
|
||||
|
||||
# CLI argument parsing
|
||||
pico-args = "0.5.0"
|
||||
|
||||
# Macro ident concatenation
|
||||
paste = "1.0.15"
|
||||
governor = "0.6.3"
|
||||
pastey = "0.1.0"
|
||||
governor = "0.10.0"
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.23"
|
||||
semver = "1.0.26"
|
||||
|
||||
# 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"
|
||||
mimalloc = { version = "0.1.46", features = ["secure"], default-features = false, optional = true }
|
||||
|
||||
which = "7.0.3"
|
||||
|
||||
# Argon2 library with support for the PHC format
|
||||
argon2 = "0.5.3"
|
||||
@@ -163,6 +173,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 +215,51 @@ lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
# Linting config
|
||||
[lints.rust]
|
||||
# https://doc.rust-lang.org/rustc/lints/groups.html
|
||||
[workspace.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"
|
||||
# Allow the following lints since these cause issues with Rust v1.84.0 or newer
|
||||
# Building Vaultwarden with Rust v1.85.0 and edition 2024 also works without issues
|
||||
if_let_rescope = "allow"
|
||||
tail_expr_drop_order = "allow"
|
||||
|
||||
[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"
|
||||
# https://rust-lang.github.io/rust-clippy/stable/index.html
|
||||
[workspace.lints.clippy]
|
||||
# Warn
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Ignore/Allow
|
||||
result_large_err = "allow"
|
||||
|
||||
# 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 +269,21 @@ 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"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
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
|
||||
|
||||
4
build.rs
4
build.rs
@@ -48,8 +48,8 @@ fn main() {
|
||||
fn run(args: &[&str]) -> Result<String, std::io::Error> {
|
||||
let out = Command::new(args[0]).args(&args[1..]).output()?;
|
||||
if !out.status.success() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
return Err(Error::new(ErrorKind::Other, "Command not successful"));
|
||||
use std::io::Error;
|
||||
return Err(Error::other("Command not successful"));
|
||||
}
|
||||
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
vault_version: "v2024.5.1b"
|
||||
vault_image_digest: "sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375"
|
||||
# Cross Compile Docker Helper Scripts v1.4.0
|
||||
vault_version: "v2025.3.1"
|
||||
vault_image_digest: "sha256:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd"
|
||||
# Cross Compile Docker Helper Scripts v1.6.1
|
||||
# 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:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894"
|
||||
rust_version: 1.86.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:v2025.3.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.3.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd]
|
||||
#
|
||||
# - 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:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd
|
||||
# [docker.io/vaultwarden/web-vault:v2025.3.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375 as vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd 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.86.0 AS build_amd64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.86.0 AS build_arm64
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.86.0 AS build_armv7
|
||||
FROM --platform=linux/amd64 ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.86.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
|
||||
@@ -75,6 +76,7 @@ RUN source /env-cargo && \
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./
|
||||
COPY ./macros ./macros
|
||||
|
||||
ARG CARGO_PROFILE=release
|
||||
|
||||
@@ -125,7 +127,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 +144,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:v2025.3.1
|
||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2025.3.1
|
||||
# [docker.io/vaultwarden/web-vault@sha256:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd]
|
||||
#
|
||||
# - 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:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd
|
||||
# [docker.io/vaultwarden/web-vault:v2025.3.1]
|
||||
#
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:1a867b4b175e85fc8602314bd83bc263c76c49787031704f16a2915567725375 as vault
|
||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:5b11739052c26dc3c2135b28dc5b072bc607f870a3e81fbbcc72e0cd1f124bcd 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:9c207bead753dda9430bdd15425c6518fc7a03d866103c516a2c6889188f5894 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.86.0-slim-bookworm AS build
|
||||
COPY --from=xx / /
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
@@ -88,24 +89,24 @@ RUN USER=root cargo new --bin /app
|
||||
WORKDIR /app
|
||||
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG ARCH_OPENSSL_LIB_DIR \
|
||||
ARCH_OPENSSL_INCLUDE_DIR
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# Some special variables if needed to override some build paths
|
||||
if [[ -n "${ARCH_OPENSSL_LIB_DIR}" && -n "${ARCH_OPENSSL_INCLUDE_DIR}" ]]; then \
|
||||
echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_LIB_DIR=${ARCH_OPENSSL_LIB_DIR}" >> /env-cargo && \
|
||||
echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_INCLUDE_DIR=${ARCH_OPENSSL_INCLUDE_DIR}" >> /env-cargo ; \
|
||||
fi && \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG=/usr/bin/$(xx-info)-pkg-config" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export OPENSSL_INCLUDE_DIR=/usr/include/$(xx-info)" >> /env-cargo && \
|
||||
echo "export OPENSSL_LIB_DIR=/usr/lib/$(xx-info)" >> /env-cargo ; \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
@@ -115,6 +116,7 @@ RUN source /env-cargo && \
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./
|
||||
COPY ./macros ./macros
|
||||
|
||||
ARG CARGO_PROFILE=release
|
||||
|
||||
@@ -185,7 +187,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 %}
|
||||
@@ -108,24 +109,24 @@ WORKDIR /app
|
||||
|
||||
{% if base == "debian" %}
|
||||
# Environment variables for Cargo on Debian based builds
|
||||
ARG ARCH_OPENSSL_LIB_DIR \
|
||||
ARCH_OPENSSL_INCLUDE_DIR
|
||||
ARG TARGET_PKG_CONFIG_PATH
|
||||
|
||||
RUN source /env-cargo && \
|
||||
if xx-info is-cross ; then \
|
||||
# Some special variables if needed to override some build paths
|
||||
if [[ -n "${ARCH_OPENSSL_LIB_DIR}" && -n "${ARCH_OPENSSL_INCLUDE_DIR}" ]]; then \
|
||||
echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_LIB_DIR=${ARCH_OPENSSL_LIB_DIR}" >> /env-cargo && \
|
||||
echo "export $(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_OPENSSL_INCLUDE_DIR=${ARCH_OPENSSL_INCLUDE_DIR}" >> /env-cargo ; \
|
||||
fi && \
|
||||
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
|
||||
# Because of this we generate the needed environment variables here which we can load in the needed steps.
|
||||
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
|
||||
echo "export PKG_CONFIG=/usr/bin/$(xx-info)-pkg-config" >> /env-cargo && \
|
||||
echo "export CROSS_COMPILE=1" >> /env-cargo && \
|
||||
echo "export OPENSSL_INCLUDE_DIR=/usr/include/$(xx-info)" >> /env-cargo && \
|
||||
echo "export OPENSSL_LIB_DIR=/usr/lib/$(xx-info)" >> /env-cargo ; \
|
||||
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
|
||||
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
|
||||
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
|
||||
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
|
||||
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
|
||||
else \
|
||||
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
|
||||
fi && \
|
||||
echo "# End of env-cargo" >> /env-cargo ; \
|
||||
fi && \
|
||||
# Output the current contents of the file
|
||||
cat /env-cargo
|
||||
@@ -142,6 +143,7 @@ RUN source /env-cargo && \
|
||||
|
||||
# Copies over *only* your manifests and build files
|
||||
COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./
|
||||
COPY ./macros ./macros
|
||||
|
||||
ARG CARGO_PROFILE=release
|
||||
|
||||
@@ -229,7 +231,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
|
||||
}
|
||||
@@ -133,8 +133,7 @@ target "debian-386" {
|
||||
platforms = ["linux/386"]
|
||||
tags = generate_tags("", "-386")
|
||||
args = {
|
||||
ARCH_OPENSSL_LIB_DIR = "/usr/lib/i386-linux-gnu"
|
||||
ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/i386-linux-gnu"
|
||||
TARGET_PKG_CONFIG_PATH = "/usr/lib/i386-linux-gnu/pkgconfig"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,20 +141,12 @@ target "debian-ppc64le" {
|
||||
inherits = ["debian"]
|
||||
platforms = ["linux/ppc64le"]
|
||||
tags = generate_tags("", "-ppc64le")
|
||||
args = {
|
||||
ARCH_OPENSSL_LIB_DIR = "/usr/lib/powerpc64le-linux-gnu"
|
||||
ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/powerpc64le-linux-gnu"
|
||||
}
|
||||
}
|
||||
|
||||
target "debian-s390x" {
|
||||
inherits = ["debian"]
|
||||
platforms = ["linux/s390x"]
|
||||
tags = generate_tags("", "-s390x")
|
||||
args = {
|
||||
ARCH_OPENSSL_LIB_DIR = "/usr/lib/s390x-linux-gnu"
|
||||
ARCH_OPENSSL_INCLUDE_DIR = "/usr/include/s390x-linux-gnu"
|
||||
}
|
||||
}
|
||||
// ==== End of unsupported Debian architecture targets ===
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
16
macros/Cargo.toml
Normal file
16
macros/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "macros"
|
||||
path = "src/lib.rs"
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.40"
|
||||
syn = "2.0.100"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
56
macros/src/lib.rs
Normal file
56
macros/src/lib.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
|
||||
#[proc_macro_derive(UuidFromParam)]
|
||||
pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream {
|
||||
let ast = syn::parse(input).unwrap();
|
||||
|
||||
impl_derive_uuid_macro(&ast)
|
||||
}
|
||||
|
||||
fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
impl<'r> rocket::request::FromParam<'r> for #name {
|
||||
type Error = ();
|
||||
|
||||
#[inline(always)]
|
||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||
if uuid::Uuid::parse_str(param).is_ok() {
|
||||
Ok(Self(param.to_string()))
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
gen_derive.into()
|
||||
}
|
||||
|
||||
#[proc_macro_derive(IdFromParam)]
|
||||
pub fn derive_id_from_param(input: TokenStream) -> TokenStream {
|
||||
let ast = syn::parse(input).unwrap();
|
||||
|
||||
impl_derive_safestring_macro(&ast)
|
||||
}
|
||||
|
||||
fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream {
|
||||
let name = &ast.ident;
|
||||
let gen_derive = quote! {
|
||||
#[automatically_derived]
|
||||
impl<'r> rocket::request::FromParam<'r> for #name {
|
||||
type Error = ();
|
||||
|
||||
#[inline(always)]
|
||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||
if param.chars().all(|c| matches!(c, 'a'..='z' | 'A'..='Z' |'0'..='9' | '-')) {
|
||||
Ok(Self(param.to_string()))
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
gen_derive.into()
|
||||
}
|
||||
@@ -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
|
||||
5
migrations/mysql/2025-01-09-172300_add_manage/up.sql
Normal file
5
migrations/mysql/2025-01-09-172300_add_manage/up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE collections_groups
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -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,5 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE collections_groups
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -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
|
||||
5
migrations/sqlite/2025-01-09-172300_add_manage/up.sql
Normal file
5
migrations/sqlite/2025-01-09-172300_add_manage/up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users_collections
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
|
||||
ALTER TABLE collections_groups
|
||||
ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE
|
||||
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.86.0"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
221
src/api/admin.rs
221
src/api/admin.rs
@@ -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,
|
||||
@@ -48,7 +50,7 @@ pub fn routes() -> Vec<Route> {
|
||||
disable_user,
|
||||
enable_user,
|
||||
remove_2fa,
|
||||
update_user_org_type,
|
||||
update_membership_type,
|
||||
update_revision_users,
|
||||
post_config,
|
||||
delete_config,
|
||||
@@ -60,6 +62,7 @@ pub fn routes() -> Vec<Route> {
|
||||
diagnostics,
|
||||
get_diagnostics_config,
|
||||
resend_user_invite,
|
||||
get_diagnostics_http,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z";
|
||||
const BASE_TEMPLATE: &str = "admin/base";
|
||||
|
||||
const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000";
|
||||
pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
fn admin_path() -> String {
|
||||
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
|
||||
@@ -167,8 +171,13 @@ struct LoginForm {
|
||||
redirect: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/", data = "<data>")]
|
||||
fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp) -> Result<Redirect, AdminResponse> {
|
||||
#[post("/", format = "application/x-www-form-urlencoded", data = "<data>")]
|
||||
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 +199,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 {
|
||||
@@ -271,15 +281,15 @@ struct InviteData {
|
||||
email: String,
|
||||
}
|
||||
|
||||
async fn get_user_or_404(uuid: &str, conn: &mut DbConn) -> ApiResult<User> {
|
||||
if let Some(user) = User::find_by_uuid(uuid, conn).await {
|
||||
async fn get_user_or_404(user_id: &UserId, conn: &mut DbConn) -> ApiResult<User> {
|
||||
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
||||
Ok(user)
|
||||
} else {
|
||||
err_code!("User doesn't exist", Status::NotFound.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/invite", data = "<data>")]
|
||||
#[post("/invite", format = "application/json", data = "<data>")]
|
||||
async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
||||
let data: InviteData = data.into_inner();
|
||||
if User::find_by_mail(&data.email, &mut conn).await.is_some() {
|
||||
@@ -290,7 +300,9 @@ 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
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
let invitation = Invitation::new(&user.email);
|
||||
invitation.save(conn).await
|
||||
@@ -303,7 +315,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
||||
Ok(Json(user.to_json(&mut conn).await))
|
||||
}
|
||||
|
||||
#[post("/test/smtp", data = "<data>")]
|
||||
#[post("/test/smtp", format = "application/json", data = "<data>")]
|
||||
async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
|
||||
let data: InviteData = data.into_inner();
|
||||
|
||||
@@ -372,29 +384,29 @@ async fn get_user_by_mail_json(mail: &str, _token: AdminToken, mut conn: DbConn)
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/users/<uuid>")]
|
||||
async fn get_user_json(uuid: &str, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
||||
let u = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[get("/users/<user_id>")]
|
||||
async fn get_user_json(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> JsonResult {
|
||||
let u = get_user_or_404(&user_id, &mut conn).await?;
|
||||
let mut usr = u.to_json(&mut conn).await;
|
||||
usr["userEnabled"] = json!(u.enabled);
|
||||
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||
Ok(Json(usr))
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/delete")]
|
||||
async fn delete_user(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let user = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||
async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
|
||||
// Get the user_org records before deleting the actual user
|
||||
let user_orgs = UserOrganization::find_any_state_by_user(uuid, &mut conn).await;
|
||||
// Get the membership records before deleting the actual user
|
||||
let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await;
|
||||
let res = user.delete(&mut conn).await;
|
||||
|
||||
for user_org in user_orgs {
|
||||
for membership in memberships {
|
||||
log_event(
|
||||
EventType::OrganizationUserRemoved as i32,
|
||||
&user_org.uuid,
|
||||
&user_org.org_uuid,
|
||||
ACTING_ADMIN_USER,
|
||||
EventType::OrganizationUserDeleted as i32,
|
||||
&membership.uuid,
|
||||
&membership.org_uuid,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
14, // Use UnknownBrowser type
|
||||
&token.ip.ip,
|
||||
&mut conn,
|
||||
@@ -405,9 +417,9 @@ async fn delete_user(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyRe
|
||||
res
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/deauth")]
|
||||
async fn deauth_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[post("/users/<user_id>/deauth", format = "application/json")]
|
||||
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
|
||||
nt.send_logout(&user, None).await;
|
||||
|
||||
@@ -426,9 +438,9 @@ async fn deauth_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notif
|
||||
user.save(&mut conn).await
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/disable")]
|
||||
async fn disable_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[post("/users/<user_id>/disable", format = "application/json")]
|
||||
async fn disable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
Device::delete_all_by_user(&user.uuid, &mut conn).await?;
|
||||
user.reset_security_stamp();
|
||||
user.enabled = false;
|
||||
@@ -440,33 +452,35 @@ async fn disable_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Noti
|
||||
save_result
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/enable")]
|
||||
async fn enable_user(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[post("/users/<user_id>/enable", format = "application/json")]
|
||||
async fn enable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
user.enabled = true;
|
||||
|
||||
user.save(&mut conn).await
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/remove-2fa")]
|
||||
async fn remove_2fa(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(uuid, &mut conn).await?;
|
||||
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
|
||||
async fn remove_2fa(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &mut conn).await?;
|
||||
two_factor::enforce_2fa_policy(&user, ACTING_ADMIN_USER, 14, &token.ip.ip, &mut conn).await?;
|
||||
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &mut conn).await?;
|
||||
user.totp_recover = None;
|
||||
user.save(&mut conn).await
|
||||
}
|
||||
|
||||
#[post("/users/<uuid>/invite/resend")]
|
||||
async fn resend_user_invite(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
if let Some(user) = User::find_by_uuid(uuid, &mut conn).await {
|
||||
#[post("/users/<user_id>/invite/resend", format = "application/json")]
|
||||
async fn resend_user_invite(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
if let Some(user) = User::find_by_uuid(&user_id, &mut conn).await {
|
||||
//TODO: replace this with user.status check when it will be available (PR#3397)
|
||||
if !user.password_hash.is_empty() {
|
||||
err_code!("User already accepted invitation", Status::BadRequest.code);
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_invite(&user.email, &user.uuid, None, None, &CONFIG.invitation_org_name(), None).await
|
||||
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -476,42 +490,41 @@ async fn resend_user_invite(uuid: &str, _token: AdminToken, mut conn: DbConn) ->
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserOrgTypeData {
|
||||
struct MembershipTypeData {
|
||||
user_type: NumberOrString,
|
||||
user_uuid: String,
|
||||
org_uuid: String,
|
||||
user_uuid: UserId,
|
||||
org_uuid: OrganizationId,
|
||||
}
|
||||
|
||||
#[post("/users/org_type", data = "<data>")]
|
||||
async fn update_user_org_type(data: Json<UserOrgTypeData>, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let data: UserOrgTypeData = data.into_inner();
|
||||
#[post("/users/org_type", format = "application/json", data = "<data>")]
|
||||
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let data: MembershipTypeData = 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 member_to_edit) = Membership::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()) {
|
||||
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid type"),
|
||||
};
|
||||
|
||||
if user_to_edit.atype == UserOrgType::Owner && new_type != UserOrgType::Owner {
|
||||
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
|
||||
// Removing owner permission, check that there is at least one other confirmed owner
|
||||
if UserOrganization::count_confirmed_by_org_and_type(&data.org_uuid, UserOrgType::Owner, &mut conn).await <= 1 {
|
||||
if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &mut conn).await <= 1 {
|
||||
err!("Can't change the type of the last owner")
|
||||
}
|
||||
}
|
||||
|
||||
// This check is also done at api::organizations::{accept_invite(), _confirm_invite, _activate_user(), edit_user()}, update_user_org_type
|
||||
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
|
||||
// It returns different error messages per function.
|
||||
if new_type < UserOrgType::Admin {
|
||||
match OrgPolicy::is_user_allowed(&user_to_edit.user_uuid, &user_to_edit.org_uuid, true, &mut conn).await {
|
||||
if new_type < MembershipType::Admin {
|
||||
match OrgPolicy::is_user_allowed(&member_to_edit.user_uuid, &member_to_edit.org_uuid, true, &mut conn).await {
|
||||
Ok(_) => {}
|
||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||
if CONFIG.email_2fa_auto_fallback() {
|
||||
two_factor::email::find_and_activate_email_2fa(&user_to_edit.user_uuid, &mut conn).await?;
|
||||
two_factor::email::find_and_activate_email_2fa(&member_to_edit.user_uuid, &mut conn).await?;
|
||||
} else {
|
||||
err!("You cannot modify this user to this type because they have not setup 2FA");
|
||||
}
|
||||
@@ -524,20 +537,20 @@ async fn update_user_org_type(data: Json<UserOrgTypeData>, token: AdminToken, mu
|
||||
|
||||
log_event(
|
||||
EventType::OrganizationUserUpdated as i32,
|
||||
&user_to_edit.uuid,
|
||||
&member_to_edit.uuid,
|
||||
&data.org_uuid,
|
||||
ACTING_ADMIN_USER,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
14, // Use UnknownBrowser type
|
||||
&token.ip.ip,
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
user_to_edit.atype = new_type;
|
||||
user_to_edit.save(&mut conn).await
|
||||
member_to_edit.atype = new_type;
|
||||
member_to_edit.save(&mut conn).await
|
||||
}
|
||||
|
||||
#[post("/users/update_revision")]
|
||||
#[post("/users/update_revision", format = "application/json")]
|
||||
async fn update_revision_users(_token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
User::update_all_revisions(&mut conn).await
|
||||
}
|
||||
@@ -548,7 +561,7 @@ async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResu
|
||||
let mut organizations_json = Vec::with_capacity(organizations.len());
|
||||
for o in organizations {
|
||||
let mut org = o.to_json();
|
||||
org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &mut conn).await);
|
||||
org["user_count"] = json!(Membership::count_by_org(&o.uuid, &mut conn).await);
|
||||
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &mut conn).await);
|
||||
org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &mut conn).await);
|
||||
org["group_count"] = json!(Group::count_by_org(&o.uuid, &mut conn).await);
|
||||
@@ -562,17 +575,12 @@ async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResu
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[post("/organizations/<uuid>/delete")]
|
||||
async fn delete_organization(uuid: &str, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let org = Organization::find_by_uuid(uuid, &mut conn).await.map_res("Organization doesn't exist")?;
|
||||
#[post("/organizations/<org_id>/delete", format = "application/json")]
|
||||
async fn delete_organization(org_id: OrganizationId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let org = Organization::find_by_uuid(&org_id, &mut conn).await.map_res("Organization doesn't exist")?;
|
||||
org.delete(&mut conn).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebVaultVersion {
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitRelease {
|
||||
tag_name: String,
|
||||
@@ -594,15 +602,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,
|
||||
}
|
||||
@@ -611,7 +618,7 @@ async fn has_http_access() -> bool {
|
||||
use cached::proc_macro::cached;
|
||||
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already.
|
||||
/// It will cache this function for 300 seconds (5 minutes) which should prevent the exhaustion of the rate limit.
|
||||
#[cached(time = 300, sync_writes = true)]
|
||||
#[cached(time = 300, sync_writes = "default")]
|
||||
async fn get_release_info(has_http_access: bool, running_within_container: bool) -> (String, String, String) {
|
||||
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
|
||||
if has_http_access {
|
||||
@@ -672,18 +679,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 +698,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 +717,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
|
||||
@@ -734,27 +733,41 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[get("/diagnostics/config")]
|
||||
#[get("/diagnostics/config", format = "application/json")]
|
||||
fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
|
||||
let support_json = CONFIG.get_support_json();
|
||||
Json(support_json)
|
||||
}
|
||||
|
||||
#[post("/config", data = "<data>")]
|
||||
#[get("/diagnostics/http?<code>")]
|
||||
fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
|
||||
err_code!(format!("Testing error {code} response"), code);
|
||||
}
|
||||
|
||||
#[post("/config", format = "application/json", 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, true) {
|
||||
err!(format!("Unable to save config: {e:?}"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/config/delete")]
|
||||
#[post("/config/delete", format = "application/json")]
|
||||
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 {
|
||||
#[post("/config/backup_db", format = "application/json")]
|
||||
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)");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -93,10 +93,10 @@ async fn get_grantees(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
}
|
||||
|
||||
#[get("/emergency-access/<emer_id>")]
|
||||
async fn get_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await {
|
||||
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(emergency_access) => Ok(Json(
|
||||
emergency_access.to_json_grantee_details(&mut conn).await.expect("Grantee user should exist but does not!"),
|
||||
)),
|
||||
@@ -118,7 +118,7 @@ struct EmergencyAccessUpdateData {
|
||||
|
||||
#[put("/emergency-access/<emer_id>", data = "<data>")]
|
||||
async fn put_emergency_access(
|
||||
emer_id: &str,
|
||||
emer_id: EmergencyAccessId,
|
||||
data: Json<EmergencyAccessUpdateData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
@@ -128,7 +128,7 @@ async fn put_emergency_access(
|
||||
|
||||
#[post("/emergency-access/<emer_id>", data = "<data>")]
|
||||
async fn post_emergency_access(
|
||||
emer_id: &str,
|
||||
emer_id: EmergencyAccessId,
|
||||
data: Json<EmergencyAccessUpdateData>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
@@ -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,
|
||||
@@ -163,12 +163,12 @@ async fn post_emergency_access(
|
||||
// region delete
|
||||
|
||||
#[delete("/emergency-access/<emer_id>")]
|
||||
async fn delete_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
||||
async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let emergency_access = match (
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(emer_id, &headers.user.uuid, &mut conn).await,
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(emer_id, &headers.user.uuid, &mut conn).await,
|
||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &mut conn).await,
|
||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &mut conn).await,
|
||||
) {
|
||||
(Some(grantor_emer), None) => {
|
||||
info!("Grantor deleted emergency access {emer_id}");
|
||||
@@ -186,7 +186,7 @@ async fn delete_emergency_access(emer_id: &str, headers: Headers, mut conn: DbCo
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/delete")]
|
||||
async fn post_delete_emergency_access(emer_id: &str, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
async fn post_delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
delete_emergency_access(emer_id, headers, conn).await
|
||||
}
|
||||
|
||||
@@ -266,8 +266,8 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_emergency_access_invite(
|
||||
&new_emergency_access.email.expect("Grantee email does not exists"),
|
||||
&grantee_user.uuid,
|
||||
&new_emergency_access.uuid,
|
||||
grantee_user.uuid,
|
||||
new_emergency_access.uuid,
|
||||
&grantor_user.name,
|
||||
&grantor_user.email,
|
||||
)
|
||||
@@ -281,27 +281,25 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/reinvite")]
|
||||
async fn resend_invite(emer_id: &str, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
||||
async fn resend_invite(emer_id: EmergencyAccessId, 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;
|
||||
@@ -309,8 +307,8 @@ async fn resend_invite(emer_id: &str, headers: Headers, mut conn: DbConn) -> Emp
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_emergency_access_invite(
|
||||
&email,
|
||||
&grantor_user.uuid,
|
||||
&emergency_access.uuid,
|
||||
grantor_user.uuid,
|
||||
emergency_access.uuid,
|
||||
&grantor_user.name,
|
||||
&grantor_user.email,
|
||||
)
|
||||
@@ -333,7 +331,12 @@ struct AcceptData {
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/accept", data = "<data>")]
|
||||
async fn accept_invite(emer_id: &str, data: Json<AcceptData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
|
||||
async fn accept_invite(
|
||||
emer_id: EmergencyAccessId,
|
||||
data: Json<AcceptData>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
) -> EmptyResult {
|
||||
check_emergency_access_enabled()?;
|
||||
|
||||
let data: AcceptData = data.into_inner();
|
||||
@@ -356,16 +359,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
|
||||
@@ -392,7 +394,7 @@ struct ConfirmData {
|
||||
|
||||
#[post("/emergency-access/<emer_id>/confirm", data = "<data>")]
|
||||
async fn confirm_emergency_access(
|
||||
emer_id: &str,
|
||||
emer_id: EmergencyAccessId,
|
||||
data: Json<ConfirmData>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
@@ -403,11 +405,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 +417,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;
|
||||
@@ -446,23 +446,22 @@ async fn confirm_emergency_access(
|
||||
// region access emergency access
|
||||
|
||||
#[post("/emergency-access/<emer_id>/initiate")]
|
||||
async fn initiate_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
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();
|
||||
@@ -485,28 +484,26 @@ async fn initiate_emergency_access(emer_id: &str, headers: Headers, mut conn: Db
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/approve")]
|
||||
async fn approve_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn approve_emergency_access(emer_id: EmergencyAccessId, 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;
|
||||
@@ -522,14 +519,14 @@ async fn approve_emergency_access(emer_id: &str, headers: Headers, mut conn: DbC
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/reject")]
|
||||
async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn reject_emergency_access(emer_id: EmergencyAccessId, 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 +535,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;
|
||||
@@ -560,14 +556,14 @@ async fn reject_emergency_access(emer_id: &str, headers: Headers, mut conn: DbCo
|
||||
// region action
|
||||
|
||||
#[post("/emergency-access/<emer_id>/view")]
|
||||
async fn view_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn view_emergency_access(emer_id: EmergencyAccessId, 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.")
|
||||
@@ -598,23 +594,22 @@ async fn view_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn
|
||||
}
|
||||
|
||||
#[post("/emergency-access/<emer_id>/takeover")]
|
||||
async fn takeover_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
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!({
|
||||
@@ -638,7 +633,7 @@ struct EmergencyAccessPasswordData {
|
||||
|
||||
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
||||
async fn password_emergency_access(
|
||||
emer_id: &str,
|
||||
emer_id: EmergencyAccessId,
|
||||
data: Json<EmergencyAccessPasswordData>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
@@ -650,19 +645,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
|
||||
@@ -673,9 +667,9 @@ async fn password_emergency_access(
|
||||
TwoFactor::delete_all_by_user(&grantor_user.uuid, &mut conn).await?;
|
||||
|
||||
// Remove grantor from all organisations unless Owner
|
||||
for user_org in UserOrganization::find_any_state_by_user(&grantor_user.uuid, &mut conn).await {
|
||||
if user_org.atype != UserOrgType::Owner as i32 {
|
||||
user_org.delete(&mut conn).await?;
|
||||
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &mut conn).await {
|
||||
if member.atype != MembershipType::Owner as i32 {
|
||||
member.delete(&mut conn).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -684,21 +678,20 @@ async fn password_emergency_access(
|
||||
// endregion
|
||||
|
||||
#[get("/emergency-access/<emer_id>/policies")]
|
||||
async fn policies_emergency_access(emer_id: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn policies_emergency_access(emer_id: EmergencyAccessId, 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);
|
||||
@@ -713,11 +706,11 @@ async fn policies_emergency_access(emer_id: &str, headers: Headers, mut conn: Db
|
||||
|
||||
fn is_valid_request(
|
||||
emergency_access: &EmergencyAccess,
|
||||
requesting_user_uuid: &str,
|
||||
requesting_user_id: &UserId,
|
||||
requested_access_type: EmergencyAccessType,
|
||||
) -> bool {
|
||||
emergency_access.grantee_uuid.is_some()
|
||||
&& emergency_access.grantee_uuid.as_ref().unwrap() == requesting_user_uuid
|
||||
&& emergency_access.grantee_uuid.as_ref().unwrap() == requesting_user_id
|
||||
&& emergency_access.status == EmergencyAccessStatus::RecoveryApproved as i32
|
||||
&& emergency_access.atype == requested_access_type as i32
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
api::{EmptyResult, JsonResult},
|
||||
auth::{AdminHeaders, Headers},
|
||||
db::{
|
||||
models::{Cipher, Event, UserOrganization},
|
||||
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
|
||||
DbConn, DbPool,
|
||||
},
|
||||
util::parse_date,
|
||||
@@ -31,7 +31,16 @@ struct EventRange {
|
||||
|
||||
// Upstream: https://github.com/bitwarden/server/blob/9ecf69d9cabce732cf2c57976dd9afa5728578fb/src/Api/Controllers/EventsController.cs#LL84C35-L84C41
|
||||
#[get("/organizations/<org_id>/events?<data..>")]
|
||||
async fn get_org_events(org_id: &str, data: EventRange, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
|
||||
async fn get_org_events(
|
||||
org_id: OrganizationId,
|
||||
data: EventRange,
|
||||
headers: AdminHeaders,
|
||||
mut conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
@@ -44,7 +53,7 @@ async fn get_org_events(org_id: &str, data: EventRange, _headers: AdminHeaders,
|
||||
parse_date(&data.end)
|
||||
};
|
||||
|
||||
Event::find_by_organization_uuid(org_id, &start_date, &end_date, &mut conn)
|
||||
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &mut conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
@@ -59,14 +68,14 @@ async fn get_org_events(org_id: &str, data: EventRange, _headers: AdminHeaders,
|
||||
}
|
||||
|
||||
#[get("/ciphers/<cipher_id>/events?<data..>")]
|
||||
async fn get_cipher_events(cipher_id: &str, data: EventRange, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
Vec::with_capacity(0)
|
||||
} else {
|
||||
let mut events_json = Vec::with_capacity(0);
|
||||
if UserOrganization::user_has_ge_admin_access_to_cipher(&headers.user.uuid, cipher_id, &mut conn).await {
|
||||
if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &mut conn).await {
|
||||
let start_date = parse_date(&data.start);
|
||||
let end_date = if let Some(before_date) = &data.continuation_token {
|
||||
parse_date(before_date)
|
||||
@@ -74,7 +83,7 @@ async fn get_cipher_events(cipher_id: &str, data: EventRange, headers: Headers,
|
||||
parse_date(&data.end)
|
||||
};
|
||||
|
||||
events_json = Event::find_by_cipher_uuid(cipher_id, &start_date, &end_date, &mut conn)
|
||||
events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &mut conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
@@ -90,14 +99,17 @@ async fn get_cipher_events(cipher_id: &str, data: EventRange, headers: Headers,
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/users/<user_org_id>/events?<data..>")]
|
||||
#[get("/organizations/<org_id>/users/<member_id>/events?<data..>")]
|
||||
async fn get_user_events(
|
||||
org_id: &str,
|
||||
user_org_id: &str,
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
data: EventRange,
|
||||
_headers: AdminHeaders,
|
||||
headers: AdminHeaders,
|
||||
mut conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
// Return an empty vec when we org events are disabled.
|
||||
// This prevents client errors
|
||||
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
|
||||
@@ -110,7 +122,7 @@ async fn get_user_events(
|
||||
parse_date(&data.end)
|
||||
};
|
||||
|
||||
Event::find_by_org_and_user_org(org_id, user_org_id, &start_date, &end_date, &mut conn)
|
||||
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &mut conn)
|
||||
.await
|
||||
.iter()
|
||||
.map(|e| e.to_json())
|
||||
@@ -152,8 +164,8 @@ struct EventCollection {
|
||||
date: String,
|
||||
|
||||
// Optional
|
||||
cipher_id: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
cipher_id: Option<CipherId>,
|
||||
organization_id: Option<OrganizationId>,
|
||||
}
|
||||
|
||||
// Upstream:
|
||||
@@ -180,11 +192,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
.await;
|
||||
}
|
||||
1600..=1699 => {
|
||||
if let Some(org_uuid) = &event.organization_id {
|
||||
if let Some(org_id) = &event.organization_id {
|
||||
_log_event(
|
||||
event.r#type,
|
||||
org_uuid,
|
||||
org_uuid,
|
||||
org_id,
|
||||
org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
@@ -197,11 +209,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
_ => {
|
||||
if let Some(cipher_uuid) = &event.cipher_id {
|
||||
if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &mut conn).await {
|
||||
if let Some(org_uuid) = cipher.organization_uuid {
|
||||
if let Some(org_id) = cipher.organization_uuid {
|
||||
_log_event(
|
||||
event.r#type,
|
||||
cipher_uuid,
|
||||
&org_uuid,
|
||||
&org_id,
|
||||
&headers.user.uuid,
|
||||
headers.device.atype,
|
||||
Some(event_date),
|
||||
@@ -218,38 +230,39 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn log_user_event(event_type: i32, user_uuid: &str, device_type: i32, ip: &IpAddr, conn: &mut DbConn) {
|
||||
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &mut DbConn) {
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_user_event(event_type, user_uuid, device_type, None, ip, conn).await;
|
||||
_log_user_event(event_type, user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
async fn _log_user_event(
|
||||
event_type: i32,
|
||||
user_uuid: &str,
|
||||
user_id: &UserId,
|
||||
device_type: i32,
|
||||
event_date: Option<NaiveDateTime>,
|
||||
ip: &IpAddr,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
let orgs = UserOrganization::get_org_uuid_by_user(user_uuid, conn).await;
|
||||
let mut events: Vec<Event> = Vec::with_capacity(orgs.len() + 1); // We need an event per org and one without an org
|
||||
let memberships = Membership::find_by_user(user_id, conn).await;
|
||||
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
|
||||
|
||||
// Upstream saves the event also without any org_uuid.
|
||||
// Upstream saves the event also without any org_id.
|
||||
let mut event = Event::new(event_type, event_date);
|
||||
event.user_uuid = Some(String::from(user_uuid));
|
||||
event.act_user_uuid = Some(String::from(user_uuid));
|
||||
event.user_uuid = Some(user_id.clone());
|
||||
event.act_user_uuid = Some(user_id.clone());
|
||||
event.device_type = Some(device_type);
|
||||
event.ip_address = Some(ip.to_string());
|
||||
events.push(event);
|
||||
|
||||
// For each org a user is a member of store these events per org
|
||||
for org_uuid in orgs {
|
||||
for membership in memberships {
|
||||
let mut event = Event::new(event_type, event_date);
|
||||
event.user_uuid = Some(String::from(user_uuid));
|
||||
event.org_uuid = Some(org_uuid);
|
||||
event.act_user_uuid = Some(String::from(user_uuid));
|
||||
event.user_uuid = Some(user_id.clone());
|
||||
event.org_uuid = Some(membership.org_uuid);
|
||||
event.org_user_uuid = Some(membership.uuid);
|
||||
event.act_user_uuid = Some(user_id.clone());
|
||||
event.device_type = Some(device_type);
|
||||
event.ip_address = Some(ip.to_string());
|
||||
events.push(event);
|
||||
@@ -261,8 +274,8 @@ async fn _log_user_event(
|
||||
pub async fn log_event(
|
||||
event_type: i32,
|
||||
source_uuid: &str,
|
||||
org_uuid: &str,
|
||||
act_user_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
act_user_id: &UserId,
|
||||
device_type: i32,
|
||||
ip: &IpAddr,
|
||||
conn: &mut DbConn,
|
||||
@@ -270,15 +283,15 @@ pub async fn log_event(
|
||||
if !CONFIG.org_events_enabled() {
|
||||
return;
|
||||
}
|
||||
_log_event(event_type, source_uuid, org_uuid, act_user_uuid, device_type, None, ip, conn).await;
|
||||
_log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn _log_event(
|
||||
event_type: i32,
|
||||
source_uuid: &str,
|
||||
org_uuid: &str,
|
||||
act_user_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
act_user_id: &UserId,
|
||||
device_type: i32,
|
||||
event_date: Option<NaiveDateTime>,
|
||||
ip: &IpAddr,
|
||||
@@ -290,31 +303,31 @@ async fn _log_event(
|
||||
// 1000..=1099 Are user events, they need to be logged via log_user_event()
|
||||
// Cipher Events
|
||||
1100..=1199 => {
|
||||
event.cipher_uuid = Some(String::from(source_uuid));
|
||||
event.cipher_uuid = Some(source_uuid.to_string().into());
|
||||
}
|
||||
// Collection Events
|
||||
1300..=1399 => {
|
||||
event.collection_uuid = Some(String::from(source_uuid));
|
||||
event.collection_uuid = Some(source_uuid.to_string().into());
|
||||
}
|
||||
// Group Events
|
||||
1400..=1499 => {
|
||||
event.group_uuid = Some(String::from(source_uuid));
|
||||
event.group_uuid = Some(source_uuid.to_string().into());
|
||||
}
|
||||
// Org User Events
|
||||
1500..=1599 => {
|
||||
event.org_user_uuid = Some(String::from(source_uuid));
|
||||
event.org_user_uuid = Some(source_uuid.to_string().into());
|
||||
}
|
||||
// 1600..=1699 Are organizational events, and they do not need the source_uuid
|
||||
// Policy Events
|
||||
1700..=1799 => {
|
||||
event.policy_uuid = Some(String::from(source_uuid));
|
||||
event.policy_uuid = Some(source_uuid.to_string().into());
|
||||
}
|
||||
// Ignore others
|
||||
_ => {}
|
||||
}
|
||||
|
||||
event.org_uuid = Some(String::from(org_uuid));
|
||||
event.act_user_uuid = Some(String::from(act_user_uuid));
|
||||
event.org_uuid = Some(org_id.clone());
|
||||
event.act_user_uuid = Some(act_user_id.clone());
|
||||
event.device_type = Some(device_type);
|
||||
event.ip_address = Some(ip.to_string());
|
||||
event.save(conn).await.unwrap_or(());
|
||||
|
||||
@@ -23,25 +23,19 @@ 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")
|
||||
#[get("/folders/<folder_id>")]
|
||||
async fn get_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
match Folder::find_by_uuid_and_user(&folder_id, &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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderData {
|
||||
pub name: String,
|
||||
pub id: Option<String>,
|
||||
pub id: Option<FolderId>,
|
||||
}
|
||||
|
||||
#[post("/folders", data = "<data>")]
|
||||
@@ -56,14 +50,20 @@ async fn post_folders(data: Json<FolderData>, headers: Headers, mut conn: DbConn
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[post("/folders/<uuid>", data = "<data>")]
|
||||
async fn post_folder(uuid: &str, data: Json<FolderData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||
put_folder(uuid, data, headers, conn, nt).await
|
||||
#[post("/folders/<folder_id>", data = "<data>")]
|
||||
async fn post_folder(
|
||||
folder_id: FolderId,
|
||||
data: Json<FolderData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
nt: Notify<'_>,
|
||||
) -> JsonResult {
|
||||
put_folder(folder_id, data, headers, conn, nt).await
|
||||
}
|
||||
|
||||
#[put("/folders/<uuid>", data = "<data>")]
|
||||
#[put("/folders/<folder_id>", data = "<data>")]
|
||||
async fn put_folder(
|
||||
uuid: &str,
|
||||
folder_id: FolderId,
|
||||
data: Json<FolderData>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
@@ -71,15 +71,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(&folder_id, &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?;
|
||||
@@ -88,22 +83,17 @@ async fn put_folder(
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[post("/folders/<uuid>/delete")]
|
||||
async fn delete_folder_post(uuid: &str, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
delete_folder(uuid, headers, conn, nt).await
|
||||
#[post("/folders/<folder_id>/delete")]
|
||||
async fn delete_folder_post(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
delete_folder(folder_id, headers, conn, nt).await
|
||||
}
|
||||
|
||||
#[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"),
|
||||
#[delete("/folders/<folder_id>")]
|
||||
async fn delete_folder(folder_id: FolderId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &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,12 +12,13 @@ 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> {
|
||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
||||
let mut hibp_routes = routes![hibp_breach];
|
||||
let mut meta_routes = routes![alive, now, version, config];
|
||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||
|
||||
let mut routes = Vec::new();
|
||||
routes.append(&mut accounts::routes());
|
||||
@@ -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 {
|
||||
@@ -183,6 +184,18 @@ fn version() -> Json<&'static str> {
|
||||
Json(crate::VERSION.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[get("/webauthn")]
|
||||
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
||||
// Prevent a 404 error, which also causes key-rotation issues
|
||||
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
|
||||
// An empty list/data also works fine
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": [],
|
||||
"continuationToken": null
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/config")]
|
||||
fn config() -> Json<Value> {
|
||||
let domain = crate::CONFIG.domain();
|
||||
@@ -190,18 +203,25 @@ 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);
|
||||
|
||||
feature_states.insert("email-verification".to_string(), true);
|
||||
feature_states.insert("unauth-ui-refresh".to_string(), true);
|
||||
|
||||
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
|
||||
// We should make sure that we keep this updated when we support the new server features
|
||||
// Version history:
|
||||
// - Individual cipher key encryption: 2023.9.1
|
||||
"version": "2024.2.0",
|
||||
// - Individual cipher key encryption: 2024.2.0
|
||||
"version": "2025.1.0",
|
||||
"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,
|
||||
};
|
||||
@@ -52,40 +52,36 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
let data = data.into_inner();
|
||||
|
||||
for user_data in &data.members {
|
||||
let mut user_created: bool = false;
|
||||
if user_data.deleted {
|
||||
// If user is marked for deletion and it exists, revoke it
|
||||
if let Some(mut user_org) =
|
||||
UserOrganization::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await
|
||||
{
|
||||
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await {
|
||||
// Only revoke a user if it is not the last confirmed owner
|
||||
let revoked = if user_org.atype == UserOrgType::Owner
|
||||
&& user_org.status == UserOrgStatus::Confirmed as i32
|
||||
let revoked = if member.atype == MembershipType::Owner
|
||||
&& member.status == MembershipStatus::Confirmed as i32
|
||||
{
|
||||
if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn).await
|
||||
<= 1
|
||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &mut conn).await <= 1
|
||||
{
|
||||
warn!("Can't revoke the last owner");
|
||||
false
|
||||
} else {
|
||||
user_org.revoke()
|
||||
member.revoke()
|
||||
}
|
||||
} else {
|
||||
user_org.revoke()
|
||||
member.revoke()
|
||||
};
|
||||
|
||||
let ext_modified = user_org.set_external_id(Some(user_data.external_id.clone()));
|
||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||
if revoked || ext_modified {
|
||||
user_org.save(&mut conn).await?;
|
||||
member.save(&mut conn).await?;
|
||||
}
|
||||
}
|
||||
// If user is part of the organization, restore it
|
||||
} else if let Some(mut user_org) =
|
||||
UserOrganization::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await
|
||||
{
|
||||
let restored = user_org.restore();
|
||||
let ext_modified = user_org.set_external_id(Some(user_data.external_id.clone()));
|
||||
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &mut conn).await {
|
||||
let restored = member.restore();
|
||||
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
|
||||
if restored || ext_modified {
|
||||
user_org.save(&mut conn).await?;
|
||||
member.save(&mut conn).await?;
|
||||
}
|
||||
} else {
|
||||
// If user is not part of the organization
|
||||
@@ -97,25 +93,25 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
new_user.save(&mut conn).await?;
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
let invitation = Invitation::new(&new_user.email);
|
||||
invitation.save(&mut conn).await?;
|
||||
Invitation::new(&new_user.email).save(&mut conn).await?;
|
||||
}
|
||||
user_created = true;
|
||||
new_user
|
||||
}
|
||||
};
|
||||
let user_org_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||
UserOrgStatus::Invited as i32
|
||||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() {
|
||||
MembershipStatus::Invited as i32
|
||||
} else {
|
||||
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
let mut new_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
||||
new_org_user.set_external_id(Some(user_data.external_id.clone()));
|
||||
new_org_user.access_all = false;
|
||||
new_org_user.atype = UserOrgType::User as i32;
|
||||
new_org_user.status = user_org_status;
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
new_member.set_external_id(Some(user_data.external_id.clone()));
|
||||
new_member.access_all = false;
|
||||
new_member.atype = MembershipType::User as i32;
|
||||
new_member.status = member_status;
|
||||
|
||||
new_org_user.save(&mut conn).await?;
|
||||
new_member.save(&mut conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||
@@ -123,15 +119,18 @@ 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?;
|
||||
if let Err(e) =
|
||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||
{
|
||||
// Upon error delete the user, invite and org member records when needed
|
||||
if user_created {
|
||||
user.delete(&mut conn).await?;
|
||||
} else {
|
||||
new_member.delete(&mut conn).await?;
|
||||
}
|
||||
|
||||
err!(format!("Error sending invite: {e:?} "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,9 +155,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
GroupUser::delete_all_by_group(&group_uuid, &mut conn).await?;
|
||||
|
||||
for ext_id in &group_data.member_external_ids {
|
||||
if let Some(user_org) = UserOrganization::find_by_external_id_and_org(ext_id, &org_id, &mut conn).await
|
||||
{
|
||||
let mut group_user = GroupUser::new(group_uuid.clone(), user_org.uuid.clone());
|
||||
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &mut conn).await {
|
||||
let mut group_user = GroupUser::new(group_uuid.clone(), member.uuid.clone());
|
||||
group_user.save(&mut conn).await?;
|
||||
}
|
||||
}
|
||||
@@ -171,20 +169,19 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
if data.overwrite_existing {
|
||||
// Generate a HashSet to quickly verify if a member is listed or not.
|
||||
let sync_members: HashSet<String> = data.members.into_iter().map(|m| m.external_id).collect();
|
||||
for user_org in UserOrganization::find_by_org(&org_id, &mut conn).await {
|
||||
if let Some(ref user_external_id) = user_org.external_id {
|
||||
for member in Membership::find_by_org(&org_id, &mut conn).await {
|
||||
if let Some(ref user_external_id) = member.external_id {
|
||||
if !sync_members.contains(user_external_id) {
|
||||
if user_org.atype == UserOrgType::Owner && user_org.status == UserOrgStatus::Confirmed as i32 {
|
||||
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
|
||||
// Removing owner, check that there is at least one other confirmed owner
|
||||
if UserOrganization::count_confirmed_by_org_and_type(&org_id, UserOrgType::Owner, &mut conn)
|
||||
.await
|
||||
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &mut conn).await
|
||||
<= 1
|
||||
{
|
||||
warn!("Can't delete the last owner");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
user_org.delete(&mut conn).await?;
|
||||
member.delete(&mut conn).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +190,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct PublicToken(String);
|
||||
pub struct PublicToken(OrganizationId);
|
||||
|
||||
#[rocket::async_trait]
|
||||
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 +207,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 +230,12 @@ 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_id) = 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 org_id: OrganizationId = org_id.to_string().into();
|
||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &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");
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::{ClientIp, Headers, Host},
|
||||
db::{models::*, DbConn, DbPool},
|
||||
util::{NumberOrString, SafeString},
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ pub struct SendData {
|
||||
file_length: Option<NumberOrString>,
|
||||
|
||||
// Used for key rotations
|
||||
pub id: Option<String>,
|
||||
pub id: Option<SendId>,
|
||||
}
|
||||
|
||||
/// Enforces the `Disable Send` policy. A non-owner/admin user belonging to
|
||||
@@ -79,9 +79,9 @@ pub struct SendData {
|
||||
/// There is also a Vaultwarden-specific `sends_allowed` config setting that
|
||||
/// controls this policy globally.
|
||||
async fn enforce_disable_send_policy(headers: &Headers, conn: &mut DbConn) -> EmptyResult {
|
||||
let user_uuid = &headers.user.uuid;
|
||||
let user_id = &headers.user.uuid;
|
||||
if !CONFIG.sends_allowed()
|
||||
|| OrgPolicy::is_applicable_to_user(user_uuid, OrgPolicyType::DisableSend, None, conn).await
|
||||
|| OrgPolicy::is_applicable_to_user(user_id, OrgPolicyType::DisableSend, None, conn).await
|
||||
{
|
||||
err!("Due to an Enterprise Policy, you are only able to delete an existing Send.")
|
||||
}
|
||||
@@ -95,9 +95,9 @@ async fn enforce_disable_send_policy(headers: &Headers, conn: &mut DbConn) -> Em
|
||||
///
|
||||
/// Ref: https://bitwarden.com/help/article/policies/#send-options
|
||||
async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &mut DbConn) -> EmptyResult {
|
||||
let user_uuid = &headers.user.uuid;
|
||||
let user_id = &headers.user.uuid;
|
||||
let hide_email = data.hide_email.unwrap_or(false);
|
||||
if hide_email && OrgPolicy::is_hide_email_disabled(user_uuid, conn).await {
|
||||
if hide_email && OrgPolicy::is_hide_email_disabled(user_id, conn).await {
|
||||
err!(
|
||||
"Due to an Enterprise Policy, you are not allowed to hide your email address \
|
||||
from recipients when creating or editing a Send."
|
||||
@@ -106,7 +106,7 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_send(data: SendData, user_uuid: String) -> ApiResult<Send> {
|
||||
fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
|
||||
let data_val = if data.r#type == SendType::Text as i32 {
|
||||
data.text
|
||||
} else if data.r#type == SendType::File as i32 {
|
||||
@@ -129,7 +129,7 @@ fn create_send(data: SendData, user_uuid: String) -> ApiResult<Send> {
|
||||
}
|
||||
|
||||
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
|
||||
send.user_uuid = Some(user_uuid);
|
||||
send.user_uuid = Some(user_id);
|
||||
send.notes = data.notes;
|
||||
send.max_access_count = match data.max_access_count {
|
||||
Some(m) => Some(m.into_i32()?),
|
||||
@@ -157,18 +157,12 @@ 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")
|
||||
#[get("/sends/<send_id>")]
|
||||
async fn get_send(send_id: SendId, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &mut conn).await {
|
||||
Some(send) => Ok(Json(send.to_json())),
|
||||
None => err!("Send not found", "Invalid send uuid or does not belong to user"),
|
||||
}
|
||||
|
||||
Ok(Json(send.to_json()))
|
||||
}
|
||||
|
||||
#[post("/sends", data = "<data>")]
|
||||
@@ -255,7 +249,7 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn:
|
||||
err!("Send content is not a file");
|
||||
}
|
||||
|
||||
let file_id = crate::crypto::generate_send_id();
|
||||
let file_id = crate::crypto::generate_send_file_id();
|
||||
let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(&send.uuid);
|
||||
let file_path = folder_path.join(&file_id);
|
||||
tokio::fs::create_dir_all(&folder_path).await?;
|
||||
@@ -330,7 +324,7 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, mut conn: DbC
|
||||
|
||||
let mut send = create_send(data, headers.user.uuid)?;
|
||||
|
||||
let file_id = crate::crypto::generate_send_id();
|
||||
let file_id = crate::crypto::generate_send_file_id();
|
||||
|
||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||
if let Some(o) = data_value.as_object_mut() {
|
||||
@@ -349,11 +343,19 @@ 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
|
||||
#[post("/sends/<send_uuid>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct SendFileData {
|
||||
id: SendFileId,
|
||||
size: u64,
|
||||
fileName: String,
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/66f95d1c443490b653e5a15d32977e2f5a3f9e32/src/Api/Tools/Controllers/SendsController.cs#L250
|
||||
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
|
||||
async fn post_send_file_v2_data(
|
||||
send_uuid: &str,
|
||||
file_id: &str,
|
||||
send_id: SendId,
|
||||
file_id: SendFileId,
|
||||
data: Form<UploadDataV2<'_>>,
|
||||
headers: Headers,
|
||||
mut conn: DbConn,
|
||||
@@ -363,19 +365,55 @@ 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_id, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found. Unable to save the file.", "Invalid send 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_user_id != &headers.user.uuid {
|
||||
err!("Send doesn't belong to user");
|
||||
if send.atype != SendType::File as i32 {
|
||||
err!("Send is not a file type send.");
|
||||
}
|
||||
|
||||
let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(send_uuid);
|
||||
let Ok(send_data) = serde_json::from_str::<SendFileData>(&send.data) else {
|
||||
err!("Unable to decode send data as json.")
|
||||
};
|
||||
|
||||
match data.data.raw_name() {
|
||||
Some(raw_file_name)
|
||||
if raw_file_name.dangerous_unsafe_unsanitized_raw() == send_data.fileName
|
||||
// be less strict only if using CLI, cf. https://github.com/dani-garcia/vaultwarden/issues/5614
|
||||
|| (headers.device.is_cli() && send_data.fileName.ends_with(raw_file_name.dangerous_unsafe_unsanitized_raw().as_str())
|
||||
) => {}
|
||||
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_id);
|
||||
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 +446,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 {
|
||||
@@ -452,7 +489,7 @@ async fn post_access(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
&send.update_users_revision(&mut conn).await,
|
||||
&String::from("00000000-0000-0000-0000-000000000000"),
|
||||
&String::from("00000000-0000-0000-0000-000000000000").into(),
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
@@ -462,16 +499,15 @@ async fn post_access(
|
||||
|
||||
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
|
||||
async fn post_access_file(
|
||||
send_id: &str,
|
||||
file_id: &str,
|
||||
send_id: SendId,
|
||||
file_id: SendFileId,
|
||||
data: Json<SendAccessData>,
|
||||
host: Host,
|
||||
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 {
|
||||
@@ -510,12 +546,12 @@ async fn post_access_file(
|
||||
UpdateType::SyncSendUpdate,
|
||||
&send,
|
||||
&send.update_users_revision(&mut conn).await,
|
||||
&String::from("00000000-0000-0000-0000-000000000000"),
|
||||
&String::from("00000000-0000-0000-0000-000000000000").into(),
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
|
||||
let token_claims = crate::auth::generate_send_claims(send_id, file_id);
|
||||
let token_claims = crate::auth::generate_send_claims(&send_id, &file_id);
|
||||
let token = crate::auth::encode_jwt(&token_claims);
|
||||
Ok(Json(json!({
|
||||
"object": "send-fileDownload",
|
||||
@@ -525,7 +561,7 @@ async fn post_access_file(
|
||||
}
|
||||
|
||||
#[get("/sends/<send_id>/<file_id>?<t>")]
|
||||
async fn download_send(send_id: SafeString, file_id: SafeString, t: &str) -> Option<NamedFile> {
|
||||
async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option<NamedFile> {
|
||||
if let Ok(claims) = crate::auth::decode_send(t) {
|
||||
if claims.sub == format!("{send_id}/{file_id}") {
|
||||
return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok();
|
||||
@@ -534,16 +570,21 @@ 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/<send_id>", data = "<data>")]
|
||||
async fn put_send(
|
||||
send_id: SendId,
|
||||
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(&send_id, &headers.user.uuid, &mut conn).await else {
|
||||
err!("Send not found", "Send send_id is invalid or does not belong to user")
|
||||
};
|
||||
|
||||
update_send_from_data(&mut send, data, &headers, &mut conn, &nt, UpdateType::SyncSendUpdate).await?;
|
||||
@@ -609,17 +650,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/<send_id>")]
|
||||
async fn delete_send(send_id: SendId, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let Some(send) = Send::find_by_uuid_and_user(&send_id, &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 +669,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/<send_id>/remove-password")]
|
||||
async fn put_remove_password(send_id: SendId, 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(&send_id, &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(
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
auth::{ClientIp, Headers},
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
},
|
||||
util::NumberOrString,
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
pub use crate::config::CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![generate_authenticator, activate_authenticator, activate_authenticator_put,]
|
||||
routes![generate_authenticator, activate_authenticator, activate_authenticator_put, disable_authenticator]
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||
@@ -95,7 +95,7 @@ async fn activate_authenticator_put(data: Json<EnableAuthenticatorData>, headers
|
||||
}
|
||||
|
||||
pub async fn validate_totp_code_str(
|
||||
user_uuid: &str,
|
||||
user_id: &UserId,
|
||||
totp_code: &str,
|
||||
secret: &str,
|
||||
ip: &ClientIp,
|
||||
@@ -105,11 +105,11 @@ pub async fn validate_totp_code_str(
|
||||
err!("TOTP code is not a number");
|
||||
}
|
||||
|
||||
validate_totp_code(user_uuid, totp_code, secret, ip, conn).await
|
||||
validate_totp_code(user_id, totp_code, secret, ip, conn).await
|
||||
}
|
||||
|
||||
pub async fn validate_totp_code(
|
||||
user_uuid: &str,
|
||||
user_id: &UserId,
|
||||
totp_code: &str,
|
||||
secret: &str,
|
||||
ip: &ClientIp,
|
||||
@@ -117,16 +117,15 @@ 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 =
|
||||
match TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Authenticator as i32, conn).await {
|
||||
Some(tf) => tf,
|
||||
_ => TwoFactor::new(user_uuid.to_string(), TwoFactorType::Authenticator, secret.to_string()),
|
||||
};
|
||||
let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await
|
||||
{
|
||||
Some(tf) => tf,
|
||||
_ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()),
|
||||
};
|
||||
|
||||
// The amount of steps back and forward in time
|
||||
// Also check if we need to disable time drifted TOTP codes.
|
||||
@@ -176,3 +175,47 @@ pub async fn validate_totp_code(
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DisableAuthenticatorData {
|
||||
key: String,
|
||||
master_password_hash: String,
|
||||
r#type: NumberOrString,
|
||||
}
|
||||
|
||||
#[delete("/two-factor/authenticator", data = "<data>")]
|
||||
async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let user = headers.user;
|
||||
let type_ = data.r#type.into_i32()?;
|
||||
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
|
||||
if twofactor.data == data.key {
|
||||
twofactor.delete(&mut conn).await?;
|
||||
log_user_event(
|
||||
EventType::UserDisabled2fa as i32,
|
||||
&user.uuid,
|
||||
headers.device.atype,
|
||||
&headers.ip.ip,
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email));
|
||||
}
|
||||
}
|
||||
|
||||
if TwoFactor::find_by_user(&user.uuid, &mut conn).await.is_empty() {
|
||||
super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn).await?;
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": false,
|
||||
"keys": type_,
|
||||
"object": "twoFactorProvider"
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ use crate::{
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User},
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::MapResult,
|
||||
util::get_reqwest_client,
|
||||
http_client::make_http_request,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -26,8 +26,8 @@ pub fn routes() -> Vec<Route> {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DuoData {
|
||||
host: String, // Duo API hostname
|
||||
ik: String, // integration key
|
||||
sk: String, // secret key
|
||||
ik: String, // client id
|
||||
sk: String, // client secret
|
||||
}
|
||||
|
||||
impl DuoData {
|
||||
@@ -111,8 +111,8 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbCo
|
||||
json!({
|
||||
"enabled": enabled,
|
||||
"host": data.host,
|
||||
"secretKey": data.sk,
|
||||
"integrationKey": data.ik,
|
||||
"clientSecret": data.sk,
|
||||
"clientId": data.ik,
|
||||
"object": "twoFactorDuo"
|
||||
})
|
||||
} else {
|
||||
@@ -129,8 +129,8 @@ async fn get_duo(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbCo
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EnableDuoData {
|
||||
host: String,
|
||||
secret_key: String,
|
||||
integration_key: String,
|
||||
client_secret: String,
|
||||
client_id: String,
|
||||
master_password_hash: Option<String>,
|
||||
otp: Option<String>,
|
||||
}
|
||||
@@ -139,8 +139,8 @@ impl From<EnableDuoData> for DuoData {
|
||||
fn from(d: EnableDuoData) -> Self {
|
||||
Self {
|
||||
host: d.host,
|
||||
ik: d.integration_key,
|
||||
sk: d.secret_key,
|
||||
ik: d.client_id,
|
||||
sk: d.client_secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ fn check_duo_fields_custom(data: &EnableDuoData) -> bool {
|
||||
st.is_empty() || s == DISABLED_MESSAGE_DEFAULT
|
||||
}
|
||||
|
||||
!empty_or_default(&data.host) && !empty_or_default(&data.secret_key) && !empty_or_default(&data.integration_key)
|
||||
!empty_or_default(&data.host) && !empty_or_default(&data.client_secret) && !empty_or_default(&data.client_id)
|
||||
}
|
||||
|
||||
#[post("/two-factor/duo", data = "<data>")]
|
||||
@@ -186,8 +186,8 @@ async fn activate_duo(data: Json<EnableDuoData>, headers: Headers, mut conn: DbC
|
||||
Ok(Json(json!({
|
||||
"enabled": true,
|
||||
"host": data.host,
|
||||
"secretKey": data.sk,
|
||||
"integrationKey": data.ik,
|
||||
"clientSecret": data.sk,
|
||||
"clientId": data.ik,
|
||||
"object": "twoFactorDuo"
|
||||
})))
|
||||
}
|
||||
@@ -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)
|
||||
@@ -231,13 +228,12 @@ const AUTH_PREFIX: &str = "AUTH";
|
||||
const DUO_PREFIX: &str = "TX";
|
||||
const APP_PREFIX: &str = "APP";
|
||||
|
||||
async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus {
|
||||
async fn get_user_duo_data(user_id: &UserId, 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(user_id, 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::{DeviceId, 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: &DeviceId,
|
||||
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: &DeviceId,
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User},
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
@@ -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
|
||||
@@ -57,10 +59,9 @@ async fn send_email_login(data: Json<SendEmailLoginData>, mut conn: DbConn) -> E
|
||||
}
|
||||
|
||||
/// Generate the token, save the data for later verification and send email to user
|
||||
pub async fn send_token(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn send_token(user_id: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::Email as i32;
|
||||
let mut twofactor =
|
||||
TwoFactor::find_by_user_and_type(user_uuid, type_, conn).await.map_res("Two factor not found")?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?;
|
||||
|
||||
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
|
||||
|
||||
@@ -171,9 +172,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) {
|
||||
@@ -197,19 +197,18 @@ async fn email(data: Json<EmailData>, headers: Headers, mut conn: DbConn) -> Jso
|
||||
}
|
||||
|
||||
/// Validate the email code when used as TwoFactor token mechanism
|
||||
pub async fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn validate_email_code_str(user_id: &UserId, token: &str, data: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
let mut email_data = EmailTokenData::from_json(data)?;
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Email as i32, conn)
|
||||
let mut twofactor = TwoFactor::find_by_user_and_type(user_id, 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 +288,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"),
|
||||
@@ -327,8 +326,8 @@ pub fn obscure_email(email: &str) -> String {
|
||||
format!("{}@{}", new_name, &domain)
|
||||
}
|
||||
|
||||
pub async fn find_and_activate_email_2fa(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
if let Some(user) = User::find_by_uuid(user_uuid, conn).await {
|
||||
pub async fn find_and_activate_email_2fa(user_id: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
if let Some(user) = User::find_by_uuid(user_id, conn).await {
|
||||
activate_email_2fa(&user, conn).await
|
||||
} else {
|
||||
err!("User not found!");
|
||||
|
||||
@@ -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
|
||||
@@ -173,17 +173,16 @@ async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Header
|
||||
|
||||
pub async fn enforce_2fa_policy(
|
||||
user: &User,
|
||||
act_uuid: &str,
|
||||
act_user_id: &UserId,
|
||||
device_type: i32,
|
||||
ip: &std::net::IpAddr,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
for member in UserOrganization::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn)
|
||||
.await
|
||||
.into_iter()
|
||||
for member in
|
||||
Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter()
|
||||
{
|
||||
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
||||
if member.atype < UserOrgType::Admin {
|
||||
if member.atype < MembershipType::Admin {
|
||||
if CONFIG.mail_enabled() {
|
||||
let org = Organization::find_by_uuid(&member.org_uuid, conn).await.unwrap();
|
||||
mail::send_2fa_removed_from_org(&user.email, &org.name).await?;
|
||||
@@ -196,7 +195,7 @@ pub async fn enforce_2fa_policy(
|
||||
EventType::OrganizationUserRevoked as i32,
|
||||
&member.uuid,
|
||||
&member.org_uuid,
|
||||
act_uuid,
|
||||
act_user_id,
|
||||
device_type,
|
||||
ip,
|
||||
conn,
|
||||
@@ -209,16 +208,16 @@ pub async fn enforce_2fa_policy(
|
||||
}
|
||||
|
||||
pub async fn enforce_2fa_policy_for_org(
|
||||
org_uuid: &str,
|
||||
act_uuid: &str,
|
||||
org_id: &OrganizationId,
|
||||
act_user_id: &UserId,
|
||||
device_type: i32,
|
||||
ip: &std::net::IpAddr,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
let org = Organization::find_by_uuid(org_uuid, conn).await.unwrap();
|
||||
for member in UserOrganization::find_confirmed_by_org(org_uuid, conn).await.into_iter() {
|
||||
let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
|
||||
for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() {
|
||||
// Don't enforce the policy for Admins and Owners.
|
||||
if member.atype < UserOrgType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() {
|
||||
if CONFIG.mail_enabled() {
|
||||
let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap();
|
||||
mail::send_2fa_removed_from_org(&user.email, &org.name).await?;
|
||||
@@ -230,8 +229,8 @@ pub async fn enforce_2fa_policy_for_org(
|
||||
log_event(
|
||||
EventType::OrganizationUserRevoked as i32,
|
||||
&member.uuid,
|
||||
org_uuid,
|
||||
act_uuid,
|
||||
org_id,
|
||||
act_user_id,
|
||||
device_type,
|
||||
ip,
|
||||
conn,
|
||||
@@ -268,10 +267,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:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{TwoFactor, TwoFactorType},
|
||||
models::{TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
@@ -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"),
|
||||
@@ -104,11 +104,11 @@ async fn verify_otp(data: Json<ProtectedActionVerify>, headers: Headers, mut con
|
||||
|
||||
pub async fn validate_protected_action_otp(
|
||||
otp: &str,
|
||||
user_uuid: &str,
|
||||
user_id: &UserId,
|
||||
delete_if_valid: bool,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
let pa = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::ProtectedActions as i32, conn)
|
||||
let pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn)
|
||||
.await
|
||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType},
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
@@ -148,7 +148,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
|
||||
)?;
|
||||
|
||||
let type_ = TwoFactorType::WebauthnRegisterChallenge;
|
||||
TwoFactor::new(user.uuid, type_, serde_json::to_string(&state)?).save(&mut conn).await?;
|
||||
TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?;
|
||||
|
||||
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
|
||||
challenge_value["status"] = "ok".into();
|
||||
@@ -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);
|
||||
@@ -353,20 +352,20 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn:
|
||||
}
|
||||
|
||||
pub async fn get_webauthn_registrations(
|
||||
user_uuid: &str,
|
||||
user_id: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
|
||||
let type_ = TwoFactorType::Webauthn as i32;
|
||||
match TwoFactor::find_by_user_and_type(user_uuid, type_, conn).await {
|
||||
match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
|
||||
None => Ok((false, Vec::new())), // If no data, return empty list
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_webauthn_login(user_uuid: &str, conn: &mut DbConn) -> JsonResult {
|
||||
pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> JsonResult {
|
||||
// Load saved credentials
|
||||
let creds: Vec<Credential> =
|
||||
get_webauthn_registrations(user_uuid, conn).await?.1.into_iter().map(|r| r.credential).collect();
|
||||
get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
|
||||
|
||||
if creds.is_empty() {
|
||||
err!("No Webauthn devices registered")
|
||||
@@ -377,7 +376,7 @@ pub async fn generate_webauthn_login(user_uuid: &str, conn: &mut DbConn) -> Json
|
||||
let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?;
|
||||
|
||||
// Save the challenge state for later validation
|
||||
TwoFactor::new(user_uuid.into(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?)
|
||||
TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?)
|
||||
.save(conn)
|
||||
.await?;
|
||||
|
||||
@@ -385,9 +384,9 @@ pub async fn generate_webauthn_login(user_uuid: &str, conn: &mut DbConn) -> Json
|
||||
Ok(Json(serde_json::to_value(response.public_key)?))
|
||||
}
|
||||
|
||||
pub async fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
|
||||
let state = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn).await {
|
||||
let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
|
||||
Some(tf) => {
|
||||
let state: AuthenticationState = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(conn).await?;
|
||||
@@ -404,7 +403,7 @@ pub async fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &mut
|
||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||
let rsp: PublicKeyCredential = rsp.into();
|
||||
|
||||
let mut registrations = get_webauthn_registrations(user_uuid, conn).await?.1;
|
||||
let mut registrations = get_webauthn_registrations(user_id, conn).await?.1;
|
||||
|
||||
// If the credential we received is migrated from U2F, enable the U2F compatibility
|
||||
//let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0);
|
||||
@@ -414,7 +413,7 @@ pub async fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &mut
|
||||
if ®.credential.cred_id == cred_id {
|
||||
reg.credential.counter = auth_data.counter;
|
||||
|
||||
TwoFactor::new(user_uuid.to_string(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?)
|
||||
.save(conn)
|
||||
.await?;
|
||||
return Ok(());
|
||||
|
||||
@@ -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() {
|
||||
@@ -92,10 +92,10 @@ async fn generate_yubikey(data: Json<PasswordOrOtpData>, headers: Headers, mut c
|
||||
|
||||
data.validate(&user, false, &mut conn).await?;
|
||||
|
||||
let user_uuid = &user.uuid;
|
||||
let user_id = &user.uuid;
|
||||
let yubikey_type = TwoFactorType::YubiKey as i32;
|
||||
|
||||
let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &mut conn).await;
|
||||
let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &mut conn).await;
|
||||
|
||||
if let Some(r) = r {
|
||||
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?;
|
||||
|
||||
@@ -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")
|
||||
@@ -62,6 +63,9 @@ static CLIENT: Lazy<Client> = Lazy::new(|| {
|
||||
// Build Regex only once since this takes a lot of time.
|
||||
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
|
||||
|
||||
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
|
||||
// It is used to prevent sending a specific header which breaks icon downloads.
|
||||
// If this function needs to be renamed, also adjust the code in `util.rs`
|
||||
#[get("/<domain>/icon.png")]
|
||||
fn icon_external(domain: &str) -> Option<Redirect> {
|
||||
if !is_valid_domain(domain) {
|
||||
@@ -69,7 +73,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 +104,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 +158,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 +185,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 +264,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 +273,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 => {
|
||||
@@ -308,9 +294,7 @@ fn get_favicons_node(
|
||||
TAG_HEAD if token.closing => {
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +337,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 +398,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 +444,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 +471,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 +620,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 +639,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 +651,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 +659,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,
|
||||
@@ -24,14 +24,14 @@ use crate::{
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![login, prelogin, identity_register]
|
||||
routes![login, prelogin, identity_register, register_verification_email, register_finish]
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<data>")]
|
||||
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult {
|
||||
let data: ConnectData = data.into_inner();
|
||||
|
||||
let mut user_uuid: Option<String> = None;
|
||||
let mut user_id: Option<UserId> = None;
|
||||
|
||||
let login_result = match data.grant_type.as_ref() {
|
||||
"refresh_token" => {
|
||||
@@ -48,7 +48,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||
|
||||
_password_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||
_password_login(data, &mut user_id, &mut conn, &client_header.ip).await
|
||||
}
|
||||
"client_credentials" => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
@@ -59,17 +59,17 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||
|
||||
_api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||
_api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await
|
||||
}
|
||||
t => err!("Invalid type", t),
|
||||
};
|
||||
|
||||
if let Some(user_uuid) = user_uuid {
|
||||
if let Some(user_id) = user_id {
|
||||
match &login_result {
|
||||
Ok(_) => {
|
||||
log_user_event(
|
||||
EventType::UserLoggedIn as i32,
|
||||
&user_uuid,
|
||||
&user_id,
|
||||
client_header.device_type,
|
||||
&client_header.ip.ip,
|
||||
&mut conn,
|
||||
@@ -80,7 +80,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||
if let Some(ev) = e.get_event() {
|
||||
log_user_event(
|
||||
ev.event as i32,
|
||||
&user_uuid,
|
||||
&user_id,
|
||||
client_header.device_type,
|
||||
&client_header.ip.ip,
|
||||
&mut conn,
|
||||
@@ -111,7 +111,7 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
||||
device.save(conn).await?;
|
||||
|
||||
@@ -120,24 +120,28 @@ 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>,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
@@ -153,28 +157,29 @@ 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());
|
||||
// Set the user_id here to be passed back used for event logging.
|
||||
*user_id = 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_id) = data.auth_request {
|
||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
|
||||
err!(
|
||||
"Auth request not found. Try again.",
|
||||
format!("IP: {}. Username: {}.", ip.ip, username),
|
||||
@@ -182,6 +187,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 +216,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 +226,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 +265,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() {
|
||||
@@ -278,10 +290,40 @@ async fn _password_login(
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
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 +339,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"
|
||||
@@ -319,7 +358,7 @@ async fn _password_login(
|
||||
|
||||
async fn _api_key_login(
|
||||
data: ConnectData,
|
||||
user_uuid: &mut Option<String>,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
@@ -328,7 +367,7 @@ async fn _api_key_login(
|
||||
|
||||
// Validate scope
|
||||
match data.scope.as_ref().unwrap().as_ref() {
|
||||
"api" => _user_api_key_login(data, user_uuid, conn, ip).await,
|
||||
"api" => _user_api_key_login(data, user_id, conn, ip).await,
|
||||
"api.organization" => _organization_api_key_login(data, conn, ip).await,
|
||||
_ => err!("Scope not supported"),
|
||||
}
|
||||
@@ -336,23 +375,22 @@ async fn _api_key_login(
|
||||
|
||||
async fn _user_api_key_login(
|
||||
data: ConnectData,
|
||||
user_uuid: &mut Option<String>,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> 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_id) = 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 client_user_id: UserId = client_user_id.into();
|
||||
let Some(user) = User::find_by_uuid(&client_user_id, conn).await else {
|
||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
|
||||
// Set the user_uuid here to be passed back used for event logging.
|
||||
*user_uuid = Some(user.uuid.clone());
|
||||
// Set the user_id here to be passed back used for event logging.
|
||||
*user_id = Some(user.uuid.clone());
|
||||
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
@@ -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() {
|
||||
@@ -402,7 +440,7 @@ async fn _user_api_key_login(
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
||||
device.save(conn).await?;
|
||||
|
||||
@@ -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,12 @@ 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_id) = 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 org_id: OrganizationId = org_id.to_string().into();
|
||||
let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else {
|
||||
err!("Invalid client_id", format!("IP: {}.", ip.ip))
|
||||
};
|
||||
|
||||
// Check API key.
|
||||
@@ -455,7 +491,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 +530,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 +556,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 +585,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 +613,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_id: &UserId,
|
||||
data: &ConnectData,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<Value> {
|
||||
let mut result = json!({
|
||||
"error" : "invalid_grant",
|
||||
"error_description" : "Two factor required.",
|
||||
@@ -578,28 +636,45 @@ async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbCo
|
||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||
|
||||
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
|
||||
let request = webauthn::generate_webauthn_login(user_uuid, conn).await?;
|
||||
let request = webauthn::generate_webauthn_login(user_id, conn).await?;
|
||||
result["TwoFactorProviders2"][provider.to_string()] = request.0;
|
||||
}
|
||||
|
||||
Some(TwoFactorType::Duo) => {
|
||||
let email = match User::find_by_uuid(user_uuid, conn).await {
|
||||
let email = match User::find_by_uuid(user_id, conn).await {
|
||||
Some(u) => u.email,
|
||||
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_id, tf_type as i32, conn).await else {
|
||||
err!("No YubiKey devices registered")
|
||||
};
|
||||
|
||||
let yubikey_metadata: yubikey::YubikeyMetadata = serde_json::from_str(&twofactor.data)?;
|
||||
@@ -610,14 +685,13 @@ 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_id, tf_type as i32, conn).await else {
|
||||
err!("No twofactor email registered")
|
||||
};
|
||||
|
||||
// Send email immediately if email is the only 2FA option
|
||||
if providers.len() == 1 {
|
||||
email::send_token(user_uuid, conn).await?
|
||||
email::send_token(user_id, conn).await?
|
||||
}
|
||||
|
||||
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
|
||||
@@ -640,7 +714,66 @@ async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
|
||||
#[post("/accounts/register", data = "<data>")]
|
||||
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, conn).await
|
||||
_register(data, false, conn).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterVerificationData {
|
||||
email: String,
|
||||
name: Option<String>,
|
||||
// receiveMarketingEmails: bool,
|
||||
}
|
||||
|
||||
#[derive(rocket::Responder)]
|
||||
enum RegisterVerificationResponse {
|
||||
NoContent(()),
|
||||
Token(Json<String>),
|
||||
}
|
||||
|
||||
#[post("/accounts/register/send-verification-email", data = "<data>")]
|
||||
async fn register_verification_email(
|
||||
data: Json<RegisterVerificationData>,
|
||||
mut conn: DbConn,
|
||||
) -> ApiResult<RegisterVerificationResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
if !CONFIG.is_signup_allowed(&data.email) {
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
|
||||
let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify();
|
||||
|
||||
let token_claims =
|
||||
crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
|
||||
let token = crate::auth::encode_jwt(&token_claims);
|
||||
|
||||
if should_send_mail {
|
||||
let user = User::find_by_mail(&data.email, &mut conn).await;
|
||||
if user.filter(|u| u.private_key.is_some()).is_some() {
|
||||
// There is still a timing side channel here in that the code
|
||||
// paths that send mail take noticeably longer than ones that
|
||||
// don't. Add a randomized sleep to mitigate this somewhat.
|
||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let delta: i32 = 100;
|
||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||
} else {
|
||||
mail::send_register_verify_email(&data.email, &token).await?;
|
||||
}
|
||||
|
||||
Ok(RegisterVerificationResponse::NoContent(()))
|
||||
} else {
|
||||
// If email verification is not required, return the token directly
|
||||
// the clients will use this token to finish the registration
|
||||
Ok(RegisterVerificationResponse::Token(Json(token)))
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/accounts/register/finish", data = "<data>")]
|
||||
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, true, conn).await
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||
@@ -672,7 +805,7 @@ struct ConnectData {
|
||||
|
||||
#[field(name = uncased("device_identifier"))]
|
||||
#[field(name = uncased("deviceidentifier"))]
|
||||
device_identifier: Option<String>,
|
||||
device_identifier: Option<DeviceId>,
|
||||
#[field(name = uncased("device_name"))]
|
||||
#[field(name = uncased("devicename"))]
|
||||
device_name: Option<String>,
|
||||
@@ -695,7 +828,7 @@ struct ConnectData {
|
||||
#[field(name = uncased("twofactorremember"))]
|
||||
two_factor_remember: Option<i32>,
|
||||
#[field(name = uncased("authrequest"))]
|
||||
auth_request: Option<String>,
|
||||
auth_request: Option<AuthRequestId>,
|
||||
}
|
||||
|
||||
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -10,7 +10,7 @@ use rocket_ws::{Message, WebSocket};
|
||||
use crate::{
|
||||
auth::{ClientIp, WsAccessTokenHeader},
|
||||
db::{
|
||||
models::{Cipher, Folder, Send as DbSend, User},
|
||||
models::{AuthRequestId, Cipher, CollectionId, DeviceId, Folder, Send as DbSend, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
Error, CONFIG,
|
||||
@@ -53,13 +53,13 @@ struct WsAccessToken {
|
||||
|
||||
struct WSEntryMapGuard {
|
||||
users: Arc<WebSocketUsers>,
|
||||
user_uuid: String,
|
||||
user_uuid: UserId,
|
||||
entry_uuid: uuid::Uuid,
|
||||
addr: IpAddr,
|
||||
}
|
||||
|
||||
impl WSEntryMapGuard {
|
||||
fn new(users: Arc<WebSocketUsers>, user_uuid: String, entry_uuid: uuid::Uuid, addr: IpAddr) -> Self {
|
||||
fn new(users: Arc<WebSocketUsers>, user_uuid: UserId, entry_uuid: uuid::Uuid, addr: IpAddr) -> Self {
|
||||
Self {
|
||||
users,
|
||||
user_uuid,
|
||||
@@ -72,7 +72,7 @@ impl WSEntryMapGuard {
|
||||
impl Drop for WSEntryMapGuard {
|
||||
fn drop(&mut self) {
|
||||
info!("Closing WS connection from {}", self.addr);
|
||||
if let Some(mut entry) = self.users.map.get_mut(&self.user_uuid) {
|
||||
if let Some(mut entry) = self.users.map.get_mut(self.user_uuid.as_ref()) {
|
||||
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ impl Drop for WSAnonymousEntryMapGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(tail_expr_drop_order)]
|
||||
#[get("/hub?<data..>")]
|
||||
fn websockets_hub<'r>(
|
||||
ws: WebSocket,
|
||||
@@ -129,7 +130,7 @@ fn websockets_hub<'r>(
|
||||
// Add a channel to send messages to this client to the map
|
||||
let entry_uuid = uuid::Uuid::new_v4();
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
|
||||
users.map.entry(claims.sub.clone()).or_default().push((entry_uuid, tx));
|
||||
users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx));
|
||||
|
||||
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
|
||||
(rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, addr))
|
||||
@@ -156,7 +157,6 @@ fn websockets_hub<'r>(
|
||||
|
||||
if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) {
|
||||
yield Message::binary(INITIAL_RESPONSE);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ fn websockets_hub<'r>(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(tail_expr_drop_order)]
|
||||
#[get("/anonymous-hub?<token..>")]
|
||||
fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result<rocket_ws::Stream!['r], Error> {
|
||||
let addr = ip.ip;
|
||||
@@ -223,7 +224,6 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
||||
|
||||
if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) {
|
||||
yield Message::binary(INITIAL_RESPONSE);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ fn serialize(val: Value) -> Vec<u8> {
|
||||
fn serialize_date(date: NaiveDateTime) -> Value {
|
||||
let seconds: i64 = date.and_utc().timestamp();
|
||||
let nanos: i64 = date.and_utc().timestamp_subsec_nanos().into();
|
||||
let timestamp = nanos << 34 | seconds;
|
||||
let timestamp = (nanos << 34) | seconds;
|
||||
|
||||
let bs = timestamp.to_be_bytes();
|
||||
|
||||
@@ -328,8 +328,8 @@ pub struct WebSocketUsers {
|
||||
}
|
||||
|
||||
impl WebSocketUsers {
|
||||
async fn send_update(&self, user_uuid: &str, data: &[u8]) {
|
||||
if let Some(user) = self.map.get(user_uuid).map(|v| v.clone()) {
|
||||
async fn send_update(&self, user_id: &UserId, data: &[u8]) {
|
||||
if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) {
|
||||
for (_, sender) in user.iter() {
|
||||
if let Err(e) = sender.send(Message::binary(data)).await {
|
||||
error!("Error sending WS update {e}");
|
||||
@@ -345,7 +345,7 @@ impl WebSocketUsers {
|
||||
return;
|
||||
}
|
||||
let data = create_update(
|
||||
vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
ut,
|
||||
None,
|
||||
);
|
||||
@@ -359,15 +359,15 @@ impl WebSocketUsers {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_logout(&self, user: &User, acting_device_uuid: Option<String>) {
|
||||
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
}
|
||||
let data = create_update(
|
||||
vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
||||
UpdateType::LogOut,
|
||||
acting_device_uuid.clone(),
|
||||
acting_device_id.clone(),
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
@@ -375,7 +375,7 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_logout(user, acting_device_uuid);
|
||||
push_logout(user, acting_device_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ impl WebSocketUsers {
|
||||
&self,
|
||||
ut: UpdateType,
|
||||
folder: &Folder,
|
||||
acting_device_uuid: &String,
|
||||
acting_device_id: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
@@ -392,12 +392,12 @@ impl WebSocketUsers {
|
||||
}
|
||||
let data = create_update(
|
||||
vec![
|
||||
("Id".into(), folder.uuid.clone().into()),
|
||||
("UserId".into(), folder.user_uuid.clone().into()),
|
||||
("Id".into(), folder.uuid.to_string().into()),
|
||||
("UserId".into(), folder.user_uuid.to_string().into()),
|
||||
("RevisionDate".into(), serialize_date(folder.updated_at)),
|
||||
],
|
||||
ut,
|
||||
Some(acting_device_uuid.into()),
|
||||
Some(acting_device_id.clone()),
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
@@ -405,7 +405,7 @@ impl WebSocketUsers {
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_folder_update(ut, folder, acting_device_uuid, conn).await;
|
||||
push_folder_update(ut, folder, acting_device_id, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,48 +413,48 @@ impl WebSocketUsers {
|
||||
&self,
|
||||
ut: UpdateType,
|
||||
cipher: &Cipher,
|
||||
user_uuids: &[String],
|
||||
acting_device_uuid: &String,
|
||||
collection_uuids: Option<Vec<String>>,
|
||||
user_ids: &[UserId],
|
||||
acting_device_id: &DeviceId,
|
||||
collection_uuids: Option<Vec<CollectionId>>,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
}
|
||||
let org_uuid = convert_option(cipher.organization_uuid.clone());
|
||||
let org_id = convert_option(cipher.organization_uuid.as_deref());
|
||||
// Depending if there are collections provided or not, we need to have different values for the following variables.
|
||||
// The user_uuid should be `null`, and the revision date should be set to now, else the clients won't sync the collection change.
|
||||
let (user_uuid, collection_uuids, revision_date) = if let Some(collection_uuids) = collection_uuids {
|
||||
let (user_id, 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.to_string().into()).collect::<Vec<Value>>()),
|
||||
serialize_date(Utc::now().naive_utc()),
|
||||
)
|
||||
} else {
|
||||
(convert_option(cipher.user_uuid.clone()), Value::Nil, serialize_date(cipher.updated_at))
|
||||
(convert_option(cipher.user_uuid.as_deref()), Value::Nil, serialize_date(cipher.updated_at))
|
||||
};
|
||||
|
||||
let data = create_update(
|
||||
vec![
|
||||
("Id".into(), cipher.uuid.clone().into()),
|
||||
("UserId".into(), user_uuid),
|
||||
("OrganizationId".into(), org_uuid),
|
||||
("Id".into(), cipher.uuid.to_string().into()),
|
||||
("UserId".into(), user_id),
|
||||
("OrganizationId".into(), org_id),
|
||||
("CollectionIds".into(), collection_uuids),
|
||||
("RevisionDate".into(), revision_date),
|
||||
],
|
||||
ut,
|
||||
Some(acting_device_uuid.into()),
|
||||
Some(acting_device_id.clone()),
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
for uuid in user_uuids {
|
||||
for uuid in user_ids {
|
||||
self.send_update(uuid, &data).await;
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() && user_uuids.len() == 1 {
|
||||
push_cipher_update(ut, cipher, acting_device_uuid, conn).await;
|
||||
if CONFIG.push_enabled() && user_ids.len() == 1 {
|
||||
push_cipher_update(ut, cipher, acting_device_id, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,20 +462,20 @@ impl WebSocketUsers {
|
||||
&self,
|
||||
ut: UpdateType,
|
||||
send: &DbSend,
|
||||
user_uuids: &[String],
|
||||
acting_device_uuid: &String,
|
||||
user_ids: &[UserId],
|
||||
acting_device_id: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
if *NOTIFICATIONS_DISABLED {
|
||||
return;
|
||||
}
|
||||
let user_uuid = convert_option(send.user_uuid.clone());
|
||||
let user_id = convert_option(send.user_uuid.as_deref());
|
||||
|
||||
let data = create_update(
|
||||
vec![
|
||||
("Id".into(), send.uuid.clone().into()),
|
||||
("UserId".into(), user_uuid),
|
||||
("Id".into(), send.uuid.to_string().into()),
|
||||
("UserId".into(), user_id),
|
||||
("RevisionDate".into(), serialize_date(send.revision_date)),
|
||||
],
|
||||
ut,
|
||||
@@ -483,20 +483,20 @@ impl WebSocketUsers {
|
||||
);
|
||||
|
||||
if CONFIG.enable_websocket() {
|
||||
for uuid in user_uuids {
|
||||
for uuid in user_ids {
|
||||
self.send_update(uuid, &data).await;
|
||||
}
|
||||
}
|
||||
if CONFIG.push_enabled() && user_uuids.len() == 1 {
|
||||
push_send_update(ut, send, acting_device_uuid, conn).await;
|
||||
if CONFIG.push_enabled() && user_ids.len() == 1 {
|
||||
push_send_update(ut, send, acting_device_id, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_auth_request(
|
||||
&self,
|
||||
user_uuid: &String,
|
||||
auth_request_uuid: &String,
|
||||
acting_device_uuid: &String,
|
||||
user_id: &UserId,
|
||||
auth_request_uuid: &str,
|
||||
acting_device_id: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
@@ -504,24 +504,24 @@ impl WebSocketUsers {
|
||||
return;
|
||||
}
|
||||
let data = create_update(
|
||||
vec![("Id".into(), auth_request_uuid.clone().into()), ("UserId".into(), user_uuid.clone().into())],
|
||||
vec![("Id".into(), auth_request_uuid.to_owned().into()), ("UserId".into(), user_id.to_string().into())],
|
||||
UpdateType::AuthRequest,
|
||||
Some(acting_device_uuid.to_string()),
|
||||
Some(acting_device_id.clone()),
|
||||
);
|
||||
if CONFIG.enable_websocket() {
|
||||
self.send_update(user_uuid, &data).await;
|
||||
self.send_update(user_id, &data).await;
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_auth_request(user_uuid.to_string(), auth_request_uuid.to_string(), conn).await;
|
||||
push_auth_request(user_id.clone(), auth_request_uuid.to_owned(), conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_auth_response(
|
||||
&self,
|
||||
user_uuid: &String,
|
||||
auth_response_uuid: &str,
|
||||
approving_device_uuid: String,
|
||||
user_id: &UserId,
|
||||
auth_request_id: &AuthRequestId,
|
||||
approving_device_id: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) {
|
||||
// Skip any processing if both WebSockets and Push are not active
|
||||
@@ -529,17 +529,16 @@ impl WebSocketUsers {
|
||||
return;
|
||||
}
|
||||
let data = create_update(
|
||||
vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())],
|
||||
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
||||
UpdateType::AuthRequestResponse,
|
||||
approving_device_uuid.clone().into(),
|
||||
Some(approving_device_id.clone()),
|
||||
);
|
||||
if CONFIG.enable_websocket() {
|
||||
self.send_update(auth_response_uuid, &data).await;
|
||||
self.send_update(user_id, &data).await;
|
||||
}
|
||||
|
||||
if CONFIG.push_enabled() {
|
||||
push_auth_response(user_uuid.to_string(), auth_response_uuid.to_string(), approving_device_uuid, conn)
|
||||
.await;
|
||||
push_auth_response(user_id, auth_request_id, approving_device_id, conn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,16 +557,16 @@ impl AnonymousWebSocketSubscriptions {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_auth_response(&self, user_uuid: &String, auth_response_uuid: &str) {
|
||||
pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) {
|
||||
if !CONFIG.enable_websocket() {
|
||||
return;
|
||||
}
|
||||
let data = create_anonymous_update(
|
||||
vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())],
|
||||
vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())],
|
||||
UpdateType::AuthRequestResponse,
|
||||
user_uuid.to_string(),
|
||||
user_id.clone(),
|
||||
);
|
||||
self.send_update(auth_response_uuid, &data).await;
|
||||
self.send_update(auth_request_id, &data).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,14 +578,14 @@ impl AnonymousWebSocketSubscriptions {
|
||||
"ReceiveMessage", // Target
|
||||
[ // Arguments
|
||||
{
|
||||
"ContextId": acting_device_uuid || Nil,
|
||||
"ContextId": acting_device_id || Nil,
|
||||
"Type": ut as i32,
|
||||
"Payload": {}
|
||||
}
|
||||
]
|
||||
]
|
||||
*/
|
||||
fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uuid: Option<String>) -> Vec<u8> {
|
||||
fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: Option<DeviceId>) -> Vec<u8> {
|
||||
use rmpv::Value as V;
|
||||
|
||||
let value = V::Array(vec![
|
||||
@@ -595,7 +594,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uui
|
||||
V::Nil,
|
||||
"ReceiveMessage".into(),
|
||||
V::Array(vec![V::Map(vec![
|
||||
("ContextId".into(), acting_device_uuid.map(|v| v.into()).unwrap_or_else(|| V::Nil)),
|
||||
("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)),
|
||||
("Type".into(), (ut as i32).into()),
|
||||
("Payload".into(), payload.into()),
|
||||
])]),
|
||||
@@ -604,7 +603,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uui
|
||||
serialize(value)
|
||||
}
|
||||
|
||||
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: String) -> Vec<u8> {
|
||||
fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: UserId) -> Vec<u8> {
|
||||
use rmpv::Value as V;
|
||||
|
||||
let value = V::Array(vec![
|
||||
@@ -615,7 +614,7 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
||||
V::Array(vec![V::Map(vec![
|
||||
("Type".into(), (ut as i32).into()),
|
||||
("Payload".into(), payload.into()),
|
||||
("UserId".into(), user_id.into()),
|
||||
("UserId".into(), user_id.to_string().into()),
|
||||
])]),
|
||||
]);
|
||||
|
||||
|
||||
129
src/api/push.rs
129
src/api/push.rs
@@ -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,
|
||||
db::models::{AuthRequestId, Cipher, Device, DeviceId, Folder, Send, User, UserId},
|
||||
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)
|
||||
@@ -124,16 +126,15 @@ pub async fn register_push_device(device: &mut Device, conn: &mut crate::db::DbC
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unregister_push_device(push_uuid: Option<String>) -> EmptyResult {
|
||||
if !CONFIG.push_enabled() || push_uuid.is_none() {
|
||||
pub async fn unregister_push_device(push_id: Option<String>) -> EmptyResult {
|
||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let auth_push_token = get_auth_push_token().await?;
|
||||
|
||||
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_id.unwrap()))?
|
||||
.header(AUTHORIZATION, auth_header)
|
||||
.send()
|
||||
.await
|
||||
@@ -147,51 +148,48 @@ pub async fn unregister_push_device(push_uuid: Option<String>) -> EmptyResult {
|
||||
pub async fn push_cipher_update(
|
||||
ut: UpdateType,
|
||||
cipher: &Cipher,
|
||||
acting_device_uuid: &String,
|
||||
acting_device_id: &DeviceId,
|
||||
conn: &mut crate::db::DbConn,
|
||||
) {
|
||||
// We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
|
||||
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_id) = &cipher.user_uuid else {
|
||||
debug!("Cipher has no uuid");
|
||||
return;
|
||||
};
|
||||
|
||||
if Device::check_user_has_push_device(user_uuid, conn).await {
|
||||
if Device::check_user_has_push_device(user_id, conn).await {
|
||||
send_to_push_relay(json!({
|
||||
"userId": user_uuid,
|
||||
"userId": user_id,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_uuid,
|
||||
"identifier": acting_device_uuid,
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_logout(user: &User, acting_device_uuid: Option<String>) {
|
||||
let acting_device_uuid: Value = acting_device_uuid.map(|v| v.into()).unwrap_or_else(|| Value::Null);
|
||||
pub fn push_logout(user: &User, acting_device_id: Option<DeviceId>) {
|
||||
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
|
||||
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user.uuid,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_uuid,
|
||||
"identifier": acting_device_uuid,
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"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)
|
||||
}
|
||||
})));
|
||||
}
|
||||
@@ -213,38 +211,38 @@ pub fn push_user_update(ut: UpdateType, user: &User) {
|
||||
pub async fn push_folder_update(
|
||||
ut: UpdateType,
|
||||
folder: &Folder,
|
||||
acting_device_uuid: &String,
|
||||
acting_device_id: &DeviceId,
|
||||
conn: &mut crate::db::DbConn,
|
||||
) {
|
||||
if Device::check_user_has_push_device(&folder.user_uuid, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": folder.user_uuid,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_uuid,
|
||||
"identifier": acting_device_uuid,
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"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)
|
||||
}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_send_update(ut: UpdateType, send: &Send, acting_device_uuid: &String, conn: &mut crate::db::DbConn) {
|
||||
pub async fn push_send_update(ut: UpdateType, send: &Send, acting_device_id: &DeviceId, conn: &mut crate::db::DbConn) {
|
||||
if let Some(s) = &send.user_uuid {
|
||||
if Device::check_user_has_push_device(s, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": send.user_uuid,
|
||||
"organizationId": (),
|
||||
"deviceId": acting_device_uuid,
|
||||
"identifier": acting_device_uuid,
|
||||
"deviceId": acting_device_id,
|
||||
"identifier": acting_device_id,
|
||||
"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)
|
||||
@@ -279,38 +284,38 @@ async fn send_to_push_relay(notification_data: Value) {
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn push_auth_request(user_uuid: String, auth_request_uuid: String, conn: &mut crate::db::DbConn) {
|
||||
if Device::check_user_has_push_device(user_uuid.as_str(), conn).await {
|
||||
pub async fn push_auth_request(user_id: UserId, auth_request_id: String, conn: &mut crate::db::DbConn) {
|
||||
if Device::check_user_has_push_device(&user_id, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user_uuid,
|
||||
"userId": user_id,
|
||||
"organizationId": (),
|
||||
"deviceId": null,
|
||||
"identifier": null,
|
||||
"type": UpdateType::AuthRequest as i32,
|
||||
"payload": {
|
||||
"id": auth_request_uuid,
|
||||
"userId": user_uuid,
|
||||
"Id": auth_request_id,
|
||||
"UserId": user_id,
|
||||
}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push_auth_response(
|
||||
user_uuid: String,
|
||||
auth_request_uuid: String,
|
||||
approving_device_uuid: String,
|
||||
user_id: &UserId,
|
||||
auth_request_id: &AuthRequestId,
|
||||
approving_device_id: &DeviceId,
|
||||
conn: &mut crate::db::DbConn,
|
||||
) {
|
||||
if Device::check_user_has_push_device(user_uuid.as_str(), conn).await {
|
||||
if Device::check_user_has_push_device(user_id, conn).await {
|
||||
tokio::task::spawn(send_to_push_relay(json!({
|
||||
"userId": user_uuid,
|
||||
"userId": user_id,
|
||||
"organizationId": (),
|
||||
"deviceId": approving_device_uuid,
|
||||
"identifier": approving_device_uuid,
|
||||
"deviceId": approving_device_id,
|
||||
"identifier": approving_device_id,
|
||||
"type": UpdateType::AuthRequestResponse as i32,
|
||||
"payload": {
|
||||
"id": auth_request_uuid,
|
||||
"userId": user_uuid,
|
||||
"Id": auth_request_id,
|
||||
"UserId": user_id,
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
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,
|
||||
db::models::{AttachmentId, CipherId},
|
||||
error::Error,
|
||||
util::{Cached, SafeString},
|
||||
util::Cached,
|
||||
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,65 @@ fn not_found() -> ApiResult<Html<String>> {
|
||||
Ok(Html(text))
|
||||
}
|
||||
|
||||
#[get("/css/vaultwarden.css")]
|
||||
fn vaultwarden_css() -> Cached<Css<String>> {
|
||||
let css_options = json!({
|
||||
"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
|
||||
@@ -98,16 +159,16 @@ async fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
|
||||
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true)
|
||||
}
|
||||
|
||||
#[get("/attachments/<uuid>/<file_id>?<token>")]
|
||||
async fn attachments(uuid: SafeString, file_id: SafeString, token: String) -> Option<NamedFile> {
|
||||
#[get("/attachments/<cipher_id>/<file_id>?<token>")]
|
||||
async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String) -> Option<NamedFile> {
|
||||
let Ok(claims) = decode_file_download(&token) else {
|
||||
return None;
|
||||
};
|
||||
if claims.sub != *uuid || claims.file_id != *file_id {
|
||||
if claims.sub != cipher_id || claims.file_id != file_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file_id)).await.ok()
|
||||
NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(cipher_id.as_ref()).join(file_id.as_ref())).await.ok()
|
||||
}
|
||||
|
||||
// We use DbConn here to let the alive healthcheck also verify the database connection.
|
||||
|
||||
354
src/auth.rs
354
src/auth.rs
@@ -1,14 +1,23 @@
|
||||
// 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::db::models::{
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, UserId,
|
||||
};
|
||||
use crate::{error::Error, CONFIG};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
@@ -26,32 +35,42 @@ static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.
|
||||
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
|
||||
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
|
||||
static JWT_FILE_DOWNLOAD_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|file_download", CONFIG.domain_origin()));
|
||||
static JWT_REGISTER_VERIFY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
|
||||
|
||||
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)?;
|
||||
@@ -127,6 +146,10 @@ pub fn decode_file_download(token: &str) -> Result<FileDownloadClaims, Error> {
|
||||
decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error> {
|
||||
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginJwtClaims {
|
||||
// Not before
|
||||
@@ -136,7 +159,7 @@ pub struct LoginJwtClaims {
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
pub sub: UserId,
|
||||
|
||||
pub premium: bool,
|
||||
pub name: String,
|
||||
@@ -157,7 +180,7 @@ pub struct LoginJwtClaims {
|
||||
// user security_stamp
|
||||
pub sstamp: String,
|
||||
// device uuid
|
||||
pub device: String,
|
||||
pub device: DeviceId,
|
||||
// [ "api", "offline_access" ]
|
||||
pub scope: Vec<String>,
|
||||
// [ "Application" ]
|
||||
@@ -173,19 +196,19 @@ pub struct InviteJwtClaims {
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
pub sub: UserId,
|
||||
|
||||
pub email: String,
|
||||
pub org_id: Option<String>,
|
||||
pub user_org_id: Option<String>,
|
||||
pub org_id: OrganizationId,
|
||||
pub member_id: MembershipId,
|
||||
pub invited_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn generate_invite_claims(
|
||||
uuid: String,
|
||||
user_id: UserId,
|
||||
email: String,
|
||||
org_id: Option<String>,
|
||||
user_org_id: Option<String>,
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
invited_by_email: Option<String>,
|
||||
) -> InviteJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
@@ -194,10 +217,10 @@ pub fn generate_invite_claims(
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_INVITE_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
sub: user_id,
|
||||
email,
|
||||
org_id,
|
||||
user_org_id,
|
||||
member_id,
|
||||
invited_by_email,
|
||||
}
|
||||
}
|
||||
@@ -211,18 +234,18 @@ pub struct EmergencyAccessInviteJwtClaims {
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
pub sub: UserId,
|
||||
|
||||
pub email: String,
|
||||
pub emer_id: String,
|
||||
pub emer_id: EmergencyAccessId,
|
||||
pub grantor_name: String,
|
||||
pub grantor_email: String,
|
||||
}
|
||||
|
||||
pub fn generate_emergency_access_invite_claims(
|
||||
uuid: String,
|
||||
user_id: UserId,
|
||||
email: String,
|
||||
emer_id: String,
|
||||
emer_id: EmergencyAccessId,
|
||||
grantor_name: String,
|
||||
grantor_email: String,
|
||||
) -> EmergencyAccessInviteJwtClaims {
|
||||
@@ -232,7 +255,7 @@ pub fn generate_emergency_access_invite_claims(
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
sub: user_id,
|
||||
email,
|
||||
emer_id,
|
||||
grantor_name,
|
||||
@@ -249,21 +272,24 @@ pub struct OrgApiKeyLoginJwtClaims {
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
pub sub: OrgApiKeyId,
|
||||
|
||||
pub client_id: String,
|
||||
pub client_sub: String,
|
||||
pub client_sub: OrganizationId,
|
||||
pub scope: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn generate_organization_api_key_login_claims(uuid: String, org_id: String) -> OrgApiKeyLoginJwtClaims {
|
||||
pub fn generate_organization_api_key_login_claims(
|
||||
org_api_key_uuid: OrgApiKeyId,
|
||||
org_id: OrganizationId,
|
||||
) -> OrgApiKeyLoginJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
OrgApiKeyLoginJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(1).unwrap()).timestamp(),
|
||||
iss: JWT_ORG_API_KEY_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
client_id: format!("organization.{org_id}"),
|
||||
sub: org_api_key_uuid,
|
||||
client_id: format!("organization.{}", org_id),
|
||||
client_sub: org_id,
|
||||
scope: vec!["api.organization".into()],
|
||||
}
|
||||
@@ -278,22 +304,49 @@ pub struct FileDownloadClaims {
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
pub sub: CipherId,
|
||||
|
||||
pub file_id: String,
|
||||
pub file_id: AttachmentId,
|
||||
}
|
||||
|
||||
pub fn generate_file_download_claims(uuid: String, file_id: String) -> FileDownloadClaims {
|
||||
pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) -> FileDownloadClaims {
|
||||
let time_now = Utc::now();
|
||||
FileDownloadClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
||||
iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
sub: cipher_id,
|
||||
file_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisterVerifyClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
|
||||
pub name: Option<String>,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
pub fn generate_register_verify_claims(email: String, name: Option<String>, verified: bool) -> RegisterVerifyClaims {
|
||||
let time_now = Utc::now();
|
||||
RegisterVerifyClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_minutes(30).unwrap()).timestamp(),
|
||||
iss: JWT_REGISTER_VERIFY_ISSUER.to_string(),
|
||||
sub: email,
|
||||
name,
|
||||
verified,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasicJwtClaims {
|
||||
// Not before
|
||||
@@ -317,14 +370,14 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims {
|
||||
pub fn generate_verify_email_claims(user_id: UserId) -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
sub: user_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +391,7 @@ pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_send_claims(send_id: &str, file_id: &str) -> BasicJwtClaims {
|
||||
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
BasicJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
@@ -357,7 +410,7 @@ use rocket::{
|
||||
};
|
||||
|
||||
use crate::db::{
|
||||
models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException},
|
||||
models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException},
|
||||
DbConn,
|
||||
};
|
||||
|
||||
@@ -379,8 +432,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,36 +510,32 @@ 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;
|
||||
let user_uuid = claims.sub;
|
||||
let device_id = claims.device;
|
||||
let user_id = claims.sub;
|
||||
|
||||
let mut conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => 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_id, &user_id, &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_id, &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.
|
||||
@@ -526,11 +573,30 @@ pub struct OrgHeaders {
|
||||
pub host: String,
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub org_user_type: UserOrgType,
|
||||
pub org_user: UserOrganization,
|
||||
pub membership_type: MembershipType,
|
||||
pub membership_status: MembershipStatus,
|
||||
pub membership: Membership,
|
||||
pub ip: ClientIp,
|
||||
}
|
||||
|
||||
impl OrgHeaders {
|
||||
fn is_member(&self) -> bool {
|
||||
// NOTE: we don't care about MembershipStatus at the moment because this is only used
|
||||
// where an invited, accepted or confirmed user is expected if this ever changes or
|
||||
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly
|
||||
self.membership_type >= MembershipType::User
|
||||
}
|
||||
fn is_confirmed_and_admin(&self) -> bool {
|
||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
|
||||
}
|
||||
fn is_confirmed_and_manager(&self) -> bool {
|
||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Manager
|
||||
}
|
||||
fn is_confirmed_and_owner(&self) -> bool {
|
||||
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
type Error = &'static str;
|
||||
@@ -541,55 +607,50 @@ impl<'r> FromRequest<'r> for OrgHeaders {
|
||||
// org_id is usually the second path param ("/organizations/<org_id>"),
|
||||
// but there are cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
let url_org_id: Option<&str> = {
|
||||
let mut url_org_id = None;
|
||||
if let Some(Ok(org_id)) = request.param::<&str>(1) {
|
||||
if uuid::Uuid::parse_str(org_id).is_ok() {
|
||||
url_org_id = Some(org_id);
|
||||
}
|
||||
let url_org_id: Option<OrganizationId> = {
|
||||
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
|
||||
Some(org_id.clone())
|
||||
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
|
||||
Some(org_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
if let Some(Ok(org_id)) = request.query_value::<&str>("organizationId") {
|
||||
if uuid::Uuid::parse_str(org_id).is_ok() {
|
||||
url_org_id = Some(org_id);
|
||||
}
|
||||
}
|
||||
|
||||
url_org_id
|
||||
};
|
||||
|
||||
match url_org_id {
|
||||
Some(org_id) => {
|
||||
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
|
||||
let mut conn = match DbConn::from_request(request).await {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
|
||||
let user = headers.user;
|
||||
let org_user = match UserOrganization::find_by_user_and_org(&user.uuid, org_id, &mut conn).await {
|
||||
Some(user) => {
|
||||
if user.status == UserOrgStatus::Confirmed as i32 {
|
||||
user
|
||||
} else {
|
||||
err_handler!("The current user isn't confirmed member of the organization")
|
||||
}
|
||||
}
|
||||
None => err_handler!("The current user isn't member of the organization"),
|
||||
let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await else {
|
||||
err_handler!("The current user isn't member of the organization");
|
||||
};
|
||||
|
||||
Outcome::Success(Self {
|
||||
host: headers.host,
|
||||
device: headers.device,
|
||||
user,
|
||||
org_user_type: {
|
||||
if let Some(org_usr_type) = UserOrgType::from_i32(org_user.atype) {
|
||||
org_usr_type
|
||||
membership_type: {
|
||||
if let Some(member_type) = MembershipType::from_i32(membership.atype) {
|
||||
member_type
|
||||
} else {
|
||||
// This should only happen if the DB is corrupted
|
||||
err_handler!("Unknown user type in the database")
|
||||
}
|
||||
},
|
||||
org_user,
|
||||
membership_status: {
|
||||
if let Some(member_status) = MembershipStatus::from_i32(membership.status) {
|
||||
// NOTE: add additional check for revoked if from_i32 is ever changed
|
||||
// to return Revoked status.
|
||||
member_status
|
||||
} else {
|
||||
err_handler!("User status is either revoked or invalid.")
|
||||
}
|
||||
},
|
||||
membership,
|
||||
ip: headers.ip,
|
||||
})
|
||||
}
|
||||
@@ -602,9 +663,9 @@ pub struct AdminHeaders {
|
||||
pub host: String,
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub org_user_type: UserOrgType,
|
||||
pub client_version: Option<String>,
|
||||
pub membership_type: MembershipType,
|
||||
pub ip: ClientIp,
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -613,15 +674,14 @@ 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 {
|
||||
if headers.is_confirmed_and_admin() {
|
||||
Outcome::Success(Self {
|
||||
host: headers.host,
|
||||
device: headers.device,
|
||||
user: headers.user,
|
||||
org_user_type: headers.org_user_type,
|
||||
client_version,
|
||||
membership_type: headers.membership_type,
|
||||
ip: headers.ip,
|
||||
org_id: headers.membership.org_uuid,
|
||||
})
|
||||
} else {
|
||||
err_handler!("You need to be Admin or Owner to call this endpoint")
|
||||
@@ -643,16 +703,16 @@ impl From<AdminHeaders> for Headers {
|
||||
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
|
||||
// but there could be cases where it is a query value.
|
||||
// First check the path, if this is not a valid uuid, try the query values.
|
||||
fn get_col_id(request: &Request<'_>) -> Option<String> {
|
||||
fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
|
||||
if let Some(Ok(col_id)) = request.param::<String>(3) {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id);
|
||||
return Some(col_id.into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Ok(col_id)) = request.query_value::<String>("collectionId") {
|
||||
if uuid::Uuid::parse_str(&col_id).is_ok() {
|
||||
return Some(col_id);
|
||||
return Some(col_id.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,6 +727,7 @@ pub struct ManagerHeaders {
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub ip: ClientIp,
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -675,7 +736,7 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.org_user_type >= UserOrgType::Manager {
|
||||
if headers.is_confirmed_and_manager() {
|
||||
match get_col_id(request) {
|
||||
Some(col_id) => {
|
||||
let mut conn = match DbConn::from_request(request).await {
|
||||
@@ -683,7 +744,7 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
||||
_ => err_handler!("Error getting DB"),
|
||||
};
|
||||
|
||||
if !Collection::can_access_collection(&headers.org_user, &col_id, &mut conn).await {
|
||||
if !Collection::can_access_collection(&headers.membership, &col_id, &mut conn).await {
|
||||
err_handler!("The current user isn't a manager for this collection")
|
||||
}
|
||||
}
|
||||
@@ -695,6 +756,7 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
|
||||
device: headers.device,
|
||||
user: headers.user,
|
||||
ip: headers.ip,
|
||||
org_id: headers.membership.org_uuid,
|
||||
})
|
||||
} else {
|
||||
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
|
||||
@@ -719,7 +781,7 @@ pub struct ManagerHeadersLoose {
|
||||
pub host: String,
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub org_user: UserOrganization,
|
||||
pub membership: Membership,
|
||||
pub ip: ClientIp,
|
||||
}
|
||||
|
||||
@@ -729,12 +791,12 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose {
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.org_user_type >= UserOrgType::Manager {
|
||||
if headers.is_confirmed_and_manager() {
|
||||
Outcome::Success(Self {
|
||||
host: headers.host,
|
||||
device: headers.device,
|
||||
user: headers.user,
|
||||
org_user: headers.org_user,
|
||||
membership: headers.membership,
|
||||
ip: headers.ip,
|
||||
})
|
||||
} else {
|
||||
@@ -757,14 +819,14 @@ impl From<ManagerHeadersLoose> for Headers {
|
||||
impl ManagerHeaders {
|
||||
pub async fn from_loose(
|
||||
h: ManagerHeadersLoose,
|
||||
collections: &Vec<String>,
|
||||
collections: &Vec<CollectionId>,
|
||||
conn: &mut DbConn,
|
||||
) -> Result<ManagerHeaders, Error> {
|
||||
for col_id in collections {
|
||||
if uuid::Uuid::parse_str(col_id).is_err() {
|
||||
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
|
||||
err!("Collection Id is malformed!");
|
||||
}
|
||||
if !Collection::can_access_collection(&h.org_user, col_id, conn).await {
|
||||
if !Collection::can_access_collection(&h.membership, col_id, conn).await {
|
||||
err!("You don't have access to all collections!");
|
||||
}
|
||||
}
|
||||
@@ -774,6 +836,7 @@ impl ManagerHeaders {
|
||||
device: h.device,
|
||||
user: h.user,
|
||||
ip: h.ip,
|
||||
org_id: h.membership.org_uuid,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -782,6 +845,7 @@ pub struct OwnerHeaders {
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
pub ip: ClientIp,
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -790,11 +854,12 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.org_user_type == UserOrgType::Owner {
|
||||
if headers.is_confirmed_and_owner() {
|
||||
Outcome::Success(Self {
|
||||
device: headers.device,
|
||||
user: headers.user,
|
||||
ip: headers.ip,
|
||||
org_id: headers.membership.org_uuid,
|
||||
})
|
||||
} else {
|
||||
err_handler!("You need to be Owner to call this endpoint")
|
||||
@@ -802,14 +867,33 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OrgMemberHeaders {
|
||||
pub host: String,
|
||||
pub user: User,
|
||||
pub org_id: OrganizationId,
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for OrgMemberHeaders {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let headers = try_outcome!(OrgHeaders::from_request(request).await);
|
||||
if headers.is_member() {
|
||||
Outcome::Success(Self {
|
||||
host: headers.host,
|
||||
user: headers.user,
|
||||
org_id: headers.membership.org_uuid,
|
||||
})
|
||||
} else {
|
||||
err_handler!("You need to be a Member of the Organization to call this endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Client IP address detection
|
||||
//
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
net::IpAddr,
|
||||
};
|
||||
|
||||
pub struct ClientIp {
|
||||
pub ip: IpAddr,
|
||||
@@ -842,6 +926,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 +977,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))
|
||||
}
|
||||
}
|
||||
|
||||
280
src/config.rs
280
src/config.rs
@@ -1,6 +1,11 @@
|
||||
use std::env::consts::EXE_SUFFIX;
|
||||
use std::process::exit;
|
||||
use std::sync::RwLock;
|
||||
use std::{
|
||||
env::consts::EXE_SUFFIX,
|
||||
process::exit,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
RwLock,
|
||||
},
|
||||
};
|
||||
|
||||
use job_scheduler_ng::Schedule;
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -9,7 +14,7 @@ use reqwest::Url;
|
||||
use crate::{
|
||||
db::DbConnType,
|
||||
error::Error,
|
||||
util::{get_env, get_env_bool, parse_experimental_client_feature_flags},
|
||||
util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags},
|
||||
};
|
||||
|
||||
static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
|
||||
@@ -17,6 +22,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");
|
||||
@@ -97,7 +104,7 @@ macro_rules! make_config {
|
||||
|
||||
let mut builder = ConfigBuilder::default();
|
||||
$($(
|
||||
builder.$name = make_config! { @getenv paste::paste!(stringify!([<$name:upper>])), $ty };
|
||||
builder.$name = make_config! { @getenv pastey::paste!(stringify!([<$name:upper>])), $ty };
|
||||
)+)+
|
||||
|
||||
builder
|
||||
@@ -109,6 +116,14 @@ macro_rules! make_config {
|
||||
serde_json::from_str(&config_str).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn clear_non_editable(&mut self) {
|
||||
$($(
|
||||
if !$editable {
|
||||
self.$name = None;
|
||||
}
|
||||
)+)+
|
||||
}
|
||||
|
||||
/// Merges the values of both builders into a new builder.
|
||||
/// If both have the same element, `other` wins.
|
||||
fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<String>) -> Self {
|
||||
@@ -118,7 +133,7 @@ macro_rules! make_config {
|
||||
builder.$name = v.clone();
|
||||
|
||||
if self.$name.is_some() {
|
||||
overrides.push(paste::paste!(stringify!([<$name:upper>])).into());
|
||||
overrides.push(pastey::paste!(stringify!([<$name:upper>])).into());
|
||||
}
|
||||
}
|
||||
)+)+
|
||||
@@ -146,6 +161,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
|
||||
}
|
||||
}
|
||||
@@ -210,7 +231,7 @@ macro_rules! make_config {
|
||||
element.insert("default".into(), serde_json::to_value(def.$name).unwrap());
|
||||
element.insert("type".into(), (_get_form_type(stringify!($ty))).into());
|
||||
element.insert("doc".into(), (_get_doc(concat!($($doc),+))).into());
|
||||
element.insert("overridden".into(), (overridden.contains(&paste::paste!(stringify!([<$name:upper>])).into())).into());
|
||||
element.insert("overridden".into(), (overridden.contains(&pastey::paste!(stringify!([<$name:upper>])).into())).into());
|
||||
element
|
||||
}),
|
||||
)+
|
||||
@@ -227,6 +248,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 +259,7 @@ macro_rules! make_config {
|
||||
"smtp_from",
|
||||
"smtp_host",
|
||||
"smtp_username",
|
||||
"_smtp_img_src",
|
||||
];
|
||||
|
||||
let cfg = {
|
||||
@@ -325,7 +348,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 +366,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 +432,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
|
||||
@@ -459,7 +484,8 @@ make_config! {
|
||||
disable_icon_download: bool, true, def, false;
|
||||
/// Allow new signups |> Controls whether new users can register. Users can be invited by the vaultwarden admin even if this is disabled
|
||||
signups_allowed: bool, true, def, true;
|
||||
/// Require email verification on signups. This will prevent logins from succeeding until the address has been verified
|
||||
/// Require email verification on signups. On new client versions, this will require verification at signup time. On older clients,
|
||||
/// this will prevent logins from succeeding until the address has been verified
|
||||
signups_verify: bool, true, def, false;
|
||||
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
||||
signups_verify_resend_time: u64, true, def, 3_600;
|
||||
@@ -484,11 +510,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 +533,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 +542,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 +557,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 +596,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 +622,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 +639,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,9 +669,11 @@ make_config! {
|
||||
duo: _enable_duo {
|
||||
/// Enabled
|
||||
_enable_duo: bool, true, def, true;
|
||||
/// Integration Key
|
||||
/// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2)
|
||||
duo_use_iframe: bool, false, def, false;
|
||||
/// Client Id
|
||||
duo_ikey: String, true, option;
|
||||
/// Secret Key
|
||||
/// Client Secret
|
||||
duo_skey: Pass, true, option;
|
||||
/// Host
|
||||
duo_host: String, true, option;
|
||||
@@ -639,7 +688,7 @@ make_config! {
|
||||
/// Use Sendmail |> Whether to send mail via the `sendmail` command
|
||||
use_sendmail: bool, true, def, false;
|
||||
/// Sendmail Command |> Which sendmail command to use. The one found in the $PATH is used if not specified.
|
||||
sendmail_command: String, true, option;
|
||||
sendmail_command: String, false, option;
|
||||
/// Host
|
||||
smtp_host: String, true, option;
|
||||
/// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY
|
||||
@@ -667,7 +716,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!
|
||||
@@ -686,7 +735,7 @@ make_config! {
|
||||
email_expiration_time: u64, true, def, 600;
|
||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||
email_attempts_limit: u64, true, def, 3;
|
||||
/// Automatically enforce at login |> Setup email 2FA provider regardless of any organization policy
|
||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
||||
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
||||
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
||||
email_2fa_auto_fallback: bool, true, def, false;
|
||||
@@ -727,6 +776,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 +834,19 @@ 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",
|
||||
"inline-menu-positioning-improvements",
|
||||
"ssh-key-vault-item",
|
||||
"ssh-agent",
|
||||
"anon-addy-self-host-alias",
|
||||
"simple-login-self-host-alias",
|
||||
"mutual-tls",
|
||||
];
|
||||
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() {
|
||||
@@ -840,12 +907,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
let command = cfg.sendmail_command.clone().unwrap_or_else(|| format!("sendmail{EXE_SUFFIX}"));
|
||||
|
||||
let mut path = std::path::PathBuf::from(&command);
|
||||
|
||||
// Check if we can find the sendmail command to execute when no absolute path is given
|
||||
if !path.is_absolute() {
|
||||
match which::which(&command) {
|
||||
Ok(result) => path = result,
|
||||
Err(_) => err!(format!("sendmail command {command:?} not found in $PATH")),
|
||||
}
|
||||
let Ok(which_path) = which::which(&command) else {
|
||||
err!(format!("sendmail command {command} not found in $PATH"))
|
||||
};
|
||||
path = which_path;
|
||||
}
|
||||
|
||||
match path.metadata() {
|
||||
@@ -879,8 +946,8 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !cfg.smtp_from.contains('@') {
|
||||
err!("SMTP_FROM does not contain a mandatory @ sign")
|
||||
if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) {
|
||||
err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from))
|
||||
}
|
||||
|
||||
if cfg._enable_email_2fa && cfg.email_token_size < 6 {
|
||||
@@ -899,12 +966,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 +1051,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 +1144,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 {
|
||||
@@ -1086,12 +1160,17 @@ impl Config {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_config(&self, other: ConfigBuilder) -> Result<(), Error> {
|
||||
pub fn update_config(&self, other: ConfigBuilder, ignore_non_editable: bool) -> Result<(), Error> {
|
||||
// Remove default values
|
||||
//let builder = other.remove(&self.inner.read().unwrap()._env);
|
||||
|
||||
// TODO: Remove values that are defaults, above only checks those set by env and not the defaults
|
||||
let builder = other;
|
||||
let mut builder = other;
|
||||
|
||||
// Remove values that are not editable
|
||||
if ignore_non_editable {
|
||||
builder.clear_non_editable();
|
||||
}
|
||||
|
||||
// Serialize now before we consume the builder
|
||||
let config_str = serde_json::to_string_pretty(&builder)?;
|
||||
@@ -1126,7 +1205,7 @@ impl Config {
|
||||
let mut _overrides = Vec::new();
|
||||
usr.merge(&other, false, &mut _overrides)
|
||||
};
|
||||
self.update_config(builder)
|
||||
self.update_config(builder, false)
|
||||
}
|
||||
|
||||
/// Tests whether an email's domain is allowed. A domain is allowed if it
|
||||
@@ -1192,7 +1271,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 +1302,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,8 +1345,9 @@ 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));
|
||||
hb.register_helper("webver", Box::new(webver));
|
||||
hb.register_helper("vwver", Box::new(vwver));
|
||||
|
||||
macro_rules! reg {
|
||||
($name:expr) => {{
|
||||
@@ -1277,6 +1358,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
|
||||
@@ -1301,6 +1387,7 @@ where
|
||||
reg!("email/protected_action", ".html");
|
||||
reg!("email/pw_hint_none", ".html");
|
||||
reg!("email/pw_hint_some", ".html");
|
||||
reg!("email/register_verify_email", ".html");
|
||||
reg!("email/send_2fa_removed_from_org", ".html");
|
||||
reg!("email/send_emergency_access_invite", ".html");
|
||||
reg!("email/send_org_invite", ".html");
|
||||
@@ -1320,17 +1407,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 +1436,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<'_>,
|
||||
@@ -1395,3 +1452,42 @@ fn to_json<'reg, 'rc>(
|
||||
out.write(&json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 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<semver::Version> = Lazy::new(|| {
|
||||
let vault_version = get_web_vault_version();
|
||||
// Use a single regex capture to extract version components
|
||||
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||
re.captures(&vault_version)
|
||||
.and_then(|c| {
|
||||
(c.len() == 4).then(|| {
|
||||
format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str())
|
||||
})
|
||||
})
|
||||
.and_then(|v| semver::Version::parse(&v).ok())
|
||||
.unwrap_or_else(|| semver::Version::parse("2024.6.2").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<semver::Version> = Lazy::new(|| {
|
||||
let vw_version = crate::VERSION.unwrap_or("1.32.5");
|
||||
// Use a single regex capture to extract version components
|
||||
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
|
||||
re.captures(vw_version)
|
||||
.and_then(|c| {
|
||||
(c.len() == 4).then(|| {
|
||||
format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str())
|
||||
})
|
||||
})
|
||||
.and_then(|v| semver::Version::parse(&v).ok())
|
||||
.unwrap_or_else(|| semver::Version::parse("1.32.5").unwrap())
|
||||
});
|
||||
|
||||
handlebars::handlebars_helper!(webver: | web_vault_version: String |
|
||||
semver::VersionReq::parse(&web_vault_version).expect("Invalid web-vault version compare string").matches(&WEB_VAULT_VERSION)
|
||||
);
|
||||
handlebars::handlebars_helper!(vwver: | vw_version: String |
|
||||
semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION)
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::num::NonZeroU32;
|
||||
use data_encoding::{Encoding, HEXLOWER};
|
||||
use ring::{digest, hmac, pbkdf2};
|
||||
|
||||
static DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256;
|
||||
const DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256;
|
||||
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
|
||||
|
||||
pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
|
||||
@@ -56,11 +56,11 @@ pub fn encode_random_bytes<const N: usize>(e: Encoding) -> String {
|
||||
pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String {
|
||||
// Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut rng = rand::rng();
|
||||
|
||||
(0..num_chars)
|
||||
.map(|_| {
|
||||
let i = rng.gen_range(0..alphabet.len());
|
||||
let i = rng.random_range(0..alphabet.len());
|
||||
alphabet[i] as char
|
||||
})
|
||||
.collect()
|
||||
@@ -84,14 +84,15 @@ pub fn generate_id<const N: usize>() -> String {
|
||||
encode_random_bytes::<N>(HEXLOWER)
|
||||
}
|
||||
|
||||
pub fn generate_send_id() -> String {
|
||||
// Send IDs are globally scoped, so make them longer to avoid collisions.
|
||||
pub fn generate_send_file_id() -> String {
|
||||
// Send File IDs are globally scoped, so make them longer to avoid collisions.
|
||||
generate_id::<32>() // 256 bits
|
||||
}
|
||||
|
||||
pub fn generate_attachment_id() -> String {
|
||||
use crate::db::models::AttachmentId;
|
||||
pub fn generate_attachment_id() -> AttachmentId {
|
||||
// Attachment IDs are scoped to a cipher, so they can be smaller.
|
||||
generate_id::<10>() // 80 bits
|
||||
AttachmentId(generate_id::<10>()) // 80 bits
|
||||
}
|
||||
|
||||
/// Generates a numeric token for email-based verifications.
|
||||
@@ -109,7 +110,6 @@ pub fn generate_api_key() -> String {
|
||||
// Constant time compare
|
||||
//
|
||||
pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
|
||||
use ring::constant_time::verify_slices_are_equal;
|
||||
|
||||
verify_slices_are_equal(a.as_ref(), b.as_ref()).is_ok()
|
||||
use subtle::ConstantTimeEq;
|
||||
a.as_ref().ct_eq(b.as_ref()).into()
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ macro_rules! generate_connections {
|
||||
DbConnType::$name => {
|
||||
#[cfg($name)]
|
||||
{
|
||||
paste::paste!{ [< $name _migrations >]::run_migrations()?; }
|
||||
pastey::paste!{ [< $name _migrations >]::run_migrations()?; }
|
||||
let manager = ConnectionManager::new(&url);
|
||||
let pool = Pool::builder()
|
||||
.max_size(CONFIG.database_max_conns())
|
||||
@@ -259,7 +259,7 @@ macro_rules! db_run {
|
||||
$($(
|
||||
#[cfg($db)]
|
||||
$crate::db::DbConnInner::$db($conn) => {
|
||||
paste::paste! {
|
||||
pastey::paste! {
|
||||
#[allow(unused)] use $crate::db::[<__ $db _schema>]::{self as schema, *};
|
||||
#[allow(unused)] use [<__ $db _model>]::*;
|
||||
}
|
||||
@@ -280,7 +280,7 @@ macro_rules! db_run {
|
||||
$($(
|
||||
#[cfg($db)]
|
||||
$crate::db::DbConnInner::$db($conn) => {
|
||||
paste::paste! {
|
||||
pastey::paste! {
|
||||
#[allow(unused)] use $crate::db::[<__ $db _schema>]::{self as schema, *};
|
||||
// @ RAW: #[allow(unused)] use [<__ $db _model>]::*;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +337,7 @@ macro_rules! db_object {
|
||||
};
|
||||
|
||||
( @db $db:ident | $( #[$attr:meta] )* | $name:ident | $( $( #[$field_attr:meta] )* $vis:vis $field:ident : $typ:ty),+) => {
|
||||
paste::paste! {
|
||||
pastey::paste! {
|
||||
#[allow(unused)] use super::*;
|
||||
#[allow(unused)] use diesel::prelude::*;
|
||||
#[allow(unused)] use $crate::db::[<__ $db _schema>]::*;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use std::io::ErrorKind;
|
||||
|
||||
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||
use derive_more::{AsRef, Deref, Display};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CipherId, OrganizationId, UserId};
|
||||
use crate::CONFIG;
|
||||
use macros::IdFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -11,8 +14,8 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(id))]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub cipher_uuid: String,
|
||||
pub id: AttachmentId,
|
||||
pub cipher_uuid: CipherId,
|
||||
pub file_name: String, // encrypted
|
||||
pub file_size: i64,
|
||||
pub akey: Option<String>,
|
||||
@@ -21,7 +24,13 @@ db_object! {
|
||||
|
||||
/// Local methods
|
||||
impl Attachment {
|
||||
pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i64, akey: Option<String>) -> Self {
|
||||
pub const fn new(
|
||||
id: AttachmentId,
|
||||
cipher_uuid: CipherId,
|
||||
file_name: String,
|
||||
file_size: i64,
|
||||
akey: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
cipher_uuid,
|
||||
@@ -117,14 +126,14 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult {
|
||||
for attachment in Attachment::find_by_cipher(cipher_uuid, conn).await {
|
||||
attachment.delete(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_id(id: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_id(id: &AttachmentId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.filter(attachments::id.eq(id.to_lowercase()))
|
||||
@@ -134,7 +143,7 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.filter(attachments::cipher_uuid.eq(cipher_uuid))
|
||||
@@ -144,7 +153,7 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn size_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn size_by_user(user_uuid: &UserId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
let result: Option<BigDecimal> = attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -161,7 +170,7 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_user(user_uuid: &UserId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -172,7 +181,7 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn size_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn size_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
let result: Option<BigDecimal> = attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -189,7 +198,7 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -203,7 +212,11 @@ impl Attachment {
|
||||
// This will return all attachments linked to the user or org
|
||||
// There is no filtering done here if the user actually has access!
|
||||
// It is used to speed up the sync process, and the matching is done in a different part.
|
||||
pub async fn find_all_by_user_and_orgs(user_uuid: &str, org_uuids: &Vec<String>, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_all_by_user_and_orgs(
|
||||
user_uuid: &UserId,
|
||||
org_uuids: &Vec<OrganizationId>,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
attachments::table
|
||||
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
|
||||
@@ -216,3 +229,20 @@ impl Attachment {
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
IdFromParam,
|
||||
)]
|
||||
pub struct AttachmentId(pub String);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use crate::crypto::ct_eq;
|
||||
use super::{DeviceId, OrganizationId, UserId};
|
||||
use crate::{crypto::ct_eq, util::format_date};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use macros::UuidFromParam;
|
||||
use serde_json::Value;
|
||||
|
||||
db_object! {
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
|
||||
@@ -7,15 +11,15 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct AuthRequest {
|
||||
pub uuid: String,
|
||||
pub user_uuid: String,
|
||||
pub organization_uuid: Option<String>,
|
||||
pub uuid: AuthRequestId,
|
||||
pub user_uuid: UserId,
|
||||
pub organization_uuid: Option<OrganizationId>,
|
||||
|
||||
pub request_device_identifier: String,
|
||||
pub request_device_identifier: DeviceId,
|
||||
pub device_type: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs
|
||||
|
||||
pub request_ip: String,
|
||||
pub response_device_id: Option<String>,
|
||||
pub response_device_id: Option<DeviceId>,
|
||||
|
||||
pub access_code: String,
|
||||
pub public_key: String,
|
||||
@@ -33,8 +37,8 @@ db_object! {
|
||||
|
||||
impl AuthRequest {
|
||||
pub fn new(
|
||||
user_uuid: String,
|
||||
request_device_identifier: String,
|
||||
user_uuid: UserId,
|
||||
request_device_identifier: DeviceId,
|
||||
device_type: i32,
|
||||
request_ip: String,
|
||||
access_code: String,
|
||||
@@ -43,7 +47,7 @@ impl AuthRequest {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: AuthRequestId(crate::util::get_uuid()),
|
||||
user_uuid,
|
||||
organization_uuid: None,
|
||||
|
||||
@@ -61,6 +65,13 @@ impl AuthRequest {
|
||||
authentication_date: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json_for_pending_device(&self) -> Value {
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
"creationDate": format_date(&self.creation_date),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use crate::db::DbConn;
|
||||
@@ -101,7 +112,7 @@ impl AuthRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::uuid.eq(uuid))
|
||||
@@ -111,7 +122,18 @@ impl AuthRequest {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, 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: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
@@ -119,6 +141,21 @@ impl AuthRequest {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user_and_requested_device(
|
||||
user_uuid: &UserId,
|
||||
device_uuid: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
.filter(auth_requests::user_uuid.eq(user_uuid))
|
||||
.filter(auth_requests::request_device_identifier.eq(device_uuid))
|
||||
.filter(auth_requests::approved.is_null())
|
||||
.order_by(auth_requests::creation_date.desc())
|
||||
.first::<AuthRequestDb>(conn).ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_created_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
auth_requests::table
|
||||
@@ -146,3 +183,21 @@ impl AuthRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct AuthRequestId(String);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::util::LowerCase;
|
||||
use crate::CONFIG;
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
Attachment, CollectionCipher, Favorite, FolderCipher, Group, User, UserOrgStatus, UserOrgType, UserOrganization,
|
||||
Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, MembershipStatus,
|
||||
MembershipType, OrganizationId, User, UserId,
|
||||
};
|
||||
|
||||
use crate::api::core::{CipherData, CipherSyncData, CipherSyncType};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -17,12 +19,12 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Cipher {
|
||||
pub uuid: String,
|
||||
pub uuid: CipherId,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_uuid: Option<String>,
|
||||
pub organization_uuid: Option<String>,
|
||||
pub user_uuid: Option<UserId>,
|
||||
pub organization_uuid: Option<OrganizationId>,
|
||||
|
||||
pub key: Option<String>,
|
||||
|
||||
@@ -30,7 +32,8 @@ db_object! {
|
||||
Login = 1,
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4
|
||||
Identity = 4,
|
||||
SshKey = 5
|
||||
*/
|
||||
pub atype: i32,
|
||||
pub name: String,
|
||||
@@ -45,10 +48,9 @@ db_object! {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum RepromptType {
|
||||
None = 0,
|
||||
Password = 1, // not currently used in server
|
||||
Password = 1,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
@@ -57,7 +59,7 @@ impl Cipher {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: CipherId(crate::util::get_uuid()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
@@ -79,21 +81,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.",
|
||||
@@ -117,12 +137,12 @@ impl Cipher {
|
||||
pub async fn to_json(
|
||||
&self,
|
||||
host: &str,
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
sync_type: CipherSyncType,
|
||||
conn: &mut DbConn,
|
||||
) -> Value {
|
||||
use crate::util::format_date;
|
||||
use crate::util::{format_date, validate_and_format_date};
|
||||
|
||||
let mut attachments_json: Value = Value::Null;
|
||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
@@ -138,16 +158,16 @@ impl Cipher {
|
||||
|
||||
// We don't need these values at all for Organizational syncs
|
||||
// Skip any other database calls if this is the case and just return false.
|
||||
let (read_only, hide_passwords) = if sync_type == CipherSyncType::User {
|
||||
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
|
||||
match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await {
|
||||
Some((ro, hp)) => (ro, hp),
|
||||
Some((ro, hp, mn)) => (ro, hp, mn),
|
||||
None => {
|
||||
error!("Cipher ownership assertion failure");
|
||||
(true, true)
|
||||
(true, true, false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(false, false)
|
||||
(false, false, false)
|
||||
};
|
||||
|
||||
let fields_json: Vec<_> = self
|
||||
@@ -155,44 +175,121 @@ 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!(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.
|
||||
if self.atype == 1 {
|
||||
if type_data_json["uris"].is_array() {
|
||||
let uri = type_data_json["uris"][0]["uri"].clone();
|
||||
type_data_json["uri"] = uri;
|
||||
} else {
|
||||
// Upstream always has an Uri key/value
|
||||
type_data_json["uri"] = Value::Null;
|
||||
// Upstream always has an `uri` key/value
|
||||
type_data_json["uri"] = Value::Null;
|
||||
if let Some(uris) = type_data_json["uris"].as_array_mut() {
|
||||
if !uris.is_empty() {
|
||||
// Fix uri match values first, they are only allowed to be a number or null
|
||||
// If it is a string, convert it to an int or null if that fails
|
||||
for uri in &mut *uris {
|
||||
if uri["match"].is_string() {
|
||||
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
|
||||
Ok(n) => json!(n),
|
||||
_ => Value::Null,
|
||||
};
|
||||
uri["match"] = match_value;
|
||||
}
|
||||
}
|
||||
type_data_json["uri"] = uris[0]["uri"].clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if `passwordRevisionDate` is a valid date, else convert it
|
||||
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
|
||||
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
|
||||
}
|
||||
}
|
||||
|
||||
// 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix invalid SSH Entries
|
||||
// This breaks at least the native mobile client if invalid
|
||||
// The only way to fix this is by setting type_data_json to `null`
|
||||
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
|
||||
if self.atype == 5
|
||||
&& (type_data_json["keyFingerprint"].as_str().is_none_or(|v| v.is_empty())
|
||||
|| type_data_json["privateKey"].as_str().is_none_or(|v| v.is_empty())
|
||||
|| type_data_json["publicKey"].as_str().is_none_or(|v| v.is_empty()))
|
||||
{
|
||||
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
|
||||
type_data_json = Value::Null;
|
||||
}
|
||||
|
||||
// Clone the type_data and add some default value.
|
||||
@@ -200,7 +297,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());
|
||||
@@ -212,7 +309,7 @@ impl Cipher {
|
||||
Cow::from(Vec::with_capacity(0))
|
||||
}
|
||||
} else {
|
||||
Cow::from(self.get_admin_collections(user_uuid.to_string(), conn).await)
|
||||
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
|
||||
};
|
||||
|
||||
// There are three types of cipher response models in upstream
|
||||
@@ -229,7 +326,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 +350,7 @@ impl Cipher {
|
||||
"secureNote": null,
|
||||
"card": null,
|
||||
"identity": null,
|
||||
"sshKey": null,
|
||||
});
|
||||
|
||||
// These values are only needed for user/default syncs
|
||||
@@ -260,7 +358,7 @@ impl Cipher {
|
||||
// Skip adding these fields in that case
|
||||
if sync_type == CipherSyncType::User {
|
||||
json_object["folderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
cipher_sync_data.cipher_folders.get(&self.uuid).map(|c| c.to_string())
|
||||
cipher_sync_data.cipher_folders.get(&self.uuid).cloned()
|
||||
} else {
|
||||
self.get_folder_uuid(user_uuid, conn).await
|
||||
});
|
||||
@@ -281,6 +379,7 @@ impl Cipher {
|
||||
2 => "secureNote",
|
||||
3 => "card",
|
||||
4 => "identity",
|
||||
5 => "sshKey",
|
||||
_ => panic!("Wrong type"),
|
||||
};
|
||||
|
||||
@@ -288,7 +387,7 @@ impl Cipher {
|
||||
json_object
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<UserId> {
|
||||
let mut user_uuids = Vec::new();
|
||||
match self.user_uuid {
|
||||
Some(ref user_uuid) => {
|
||||
@@ -299,17 +398,16 @@ impl Cipher {
|
||||
// Belongs to Organization, need to update affected users
|
||||
if let Some(ref org_uuid) = self.organization_uuid {
|
||||
// users having access to the collection
|
||||
let mut collection_users =
|
||||
UserOrganization::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await;
|
||||
let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await;
|
||||
if CONFIG.org_groups_enabled() {
|
||||
// members of a group having access to the collection
|
||||
let group_users =
|
||||
UserOrganization::find_by_cipher_and_org_with_group(&self.uuid, org_uuid, conn).await;
|
||||
Membership::find_by_cipher_and_org_with_group(&self.uuid, org_uuid, conn).await;
|
||||
collection_users.extend(group_users);
|
||||
}
|
||||
for user_org in collection_users {
|
||||
User::update_uuid_revision(&user_org.user_uuid, conn).await;
|
||||
user_uuids.push(user_org.user_uuid.clone())
|
||||
for member in collection_users {
|
||||
User::update_uuid_revision(&member.user_uuid, conn).await;
|
||||
user_uuids.push(member.user_uuid.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +465,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> EmptyResult {
|
||||
// TODO: Optimize this by executing a DELETE directly on the database, instead of first fetching.
|
||||
for cipher in Self::find_by_org(org_uuid, conn).await {
|
||||
cipher.delete(conn).await?;
|
||||
@@ -375,7 +473,7 @@ impl Cipher {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
for cipher in Self::find_owned_by_user(user_uuid, conn).await {
|
||||
cipher.delete(conn).await?;
|
||||
}
|
||||
@@ -393,52 +491,59 @@ impl Cipher {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn move_to_folder(
|
||||
&self,
|
||||
folder_uuid: Option<FolderId>,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
|
||||
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
||||
// No changes
|
||||
(None, None) => Ok(()),
|
||||
(Some(ref old), Some(ref new)) if old == new => Ok(()),
|
||||
(Some(ref old_folder), Some(ref new_folder)) if old_folder == new_folder => Ok(()),
|
||||
|
||||
// Add to folder
|
||||
(None, Some(new)) => FolderCipher::new(&new, &self.uuid).save(conn).await,
|
||||
(None, Some(new_folder)) => FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await,
|
||||
|
||||
// Remove from folder
|
||||
(Some(old), None) => match FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, conn).await {
|
||||
Some(old) => old.delete(conn).await,
|
||||
None => err!("Couldn't move from previous folder"),
|
||||
},
|
||||
(Some(old_folder), None) => {
|
||||
match FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await {
|
||||
Some(old_folder) => old_folder.delete(conn).await,
|
||||
None => err!("Couldn't move from previous folder"),
|
||||
}
|
||||
}
|
||||
|
||||
// Move to another folder
|
||||
(Some(old), Some(new)) => {
|
||||
if let Some(old) = FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, conn).await {
|
||||
old.delete(conn).await?;
|
||||
(Some(old_folder), Some(new_folder)) => {
|
||||
if let Some(old_folder) = FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await {
|
||||
old_folder.delete(conn).await?;
|
||||
}
|
||||
FolderCipher::new(&new, &self.uuid).save(conn).await
|
||||
FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this cipher is directly owned by the user.
|
||||
pub fn is_owned_by_user(&self, user_uuid: &str) -> bool {
|
||||
pub fn is_owned_by_user(&self, user_uuid: &UserId) -> bool {
|
||||
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
|
||||
}
|
||||
|
||||
/// Returns whether this cipher is owned by an org in which the user has full access.
|
||||
async fn is_in_full_access_org(
|
||||
&self,
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
if let Some(ref org_uuid) = self.organization_uuid {
|
||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
if let Some(cached_user_org) = cipher_sync_data.user_organizations.get(org_uuid) {
|
||||
return cached_user_org.has_full_access();
|
||||
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
|
||||
return cached_member.has_full_access();
|
||||
}
|
||||
} else if let Some(user_org) = UserOrganization::find_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||
return user_org.has_full_access();
|
||||
} else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await {
|
||||
return member.has_full_access();
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -447,7 +552,7 @@ impl Cipher {
|
||||
/// Returns whether this cipher is owned by an group in which the user has full access.
|
||||
async fn is_in_full_access_group(
|
||||
&self,
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
@@ -467,14 +572,14 @@ impl Cipher {
|
||||
/// Returns the user's access restrictions to this cipher. A return value
|
||||
/// of None means that this cipher does not belong to the user, and is
|
||||
/// not in any collection the user has access to. Otherwise, the user has
|
||||
/// access to this cipher, and Some(read_only, hide_passwords) represents
|
||||
/// access to this cipher, and Some(read_only, hide_passwords, manage) represents
|
||||
/// the access restrictions.
|
||||
pub async fn get_access_restrictions(
|
||||
&self,
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
cipher_sync_data: Option<&CipherSyncData>,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<(bool, bool)> {
|
||||
) -> Option<(bool, bool, bool)> {
|
||||
// Check whether this cipher is directly owned by the user, or is in
|
||||
// a collection that the user has full access to. If so, there are no
|
||||
// access restrictions.
|
||||
@@ -482,21 +587,21 @@ impl Cipher {
|
||||
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await
|
||||
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
|
||||
{
|
||||
return Some((false, false));
|
||||
return Some((false, false, true));
|
||||
}
|
||||
|
||||
let rows = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
let mut rows: Vec<(bool, bool)> = Vec::new();
|
||||
let mut rows: Vec<(bool, bool, bool)> = Vec::new();
|
||||
if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
||||
for collection in collections {
|
||||
//User permissions
|
||||
if let Some(uc) = cipher_sync_data.user_collections.get(collection) {
|
||||
rows.push((uc.read_only, uc.hide_passwords));
|
||||
if let Some(cu) = cipher_sync_data.user_collections.get(collection) {
|
||||
rows.push((cu.read_only, cu.hide_passwords, cu.manage));
|
||||
}
|
||||
|
||||
//Group permissions
|
||||
if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) {
|
||||
rows.push((cg.read_only, cg.hide_passwords));
|
||||
rows.push((cg.read_only, cg.hide_passwords, cg.manage));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,15 +628,21 @@ impl Cipher {
|
||||
// booleans and this behavior isn't portable anyway.
|
||||
let mut read_only = true;
|
||||
let mut hide_passwords = true;
|
||||
for (ro, hp) in rows.iter() {
|
||||
let mut manage = false;
|
||||
for (ro, hp, mn) in rows.iter() {
|
||||
read_only &= ro;
|
||||
hide_passwords &= hp;
|
||||
manage &= mn;
|
||||
}
|
||||
|
||||
Some((read_only, hide_passwords))
|
||||
Some((read_only, hide_passwords, manage))
|
||||
}
|
||||
|
||||
async fn get_user_collections_access_flags(&self, user_uuid: &str, conn: &mut DbConn) -> Vec<(bool, bool)> {
|
||||
async fn get_user_collections_access_flags(
|
||||
&self,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<(bool, bool, bool)> {
|
||||
db_run! {conn: {
|
||||
// Check whether this cipher is in any collections accessible to the
|
||||
// user. If so, retrieve the access flags for each collection.
|
||||
@@ -542,13 +653,17 @@ impl Cipher {
|
||||
.inner_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))))
|
||||
.select((users_collections::read_only, users_collections::hide_passwords))
|
||||
.load::<(bool, bool)>(conn)
|
||||
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<(bool, bool, bool)>(conn)
|
||||
.expect("Error getting user access restrictions")
|
||||
}}
|
||||
}
|
||||
|
||||
async fn get_group_collections_access_flags(&self, user_uuid: &str, conn: &mut DbConn) -> Vec<(bool, bool)> {
|
||||
async fn get_group_collections_access_flags(
|
||||
&self,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<(bool, bool, bool)> {
|
||||
if !CONFIG.org_groups_enabled() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -568,49 +683,49 @@ impl Cipher {
|
||||
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
|
||||
))
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.select((collections_groups::read_only, collections_groups::hide_passwords))
|
||||
.load::<(bool, bool)>(conn)
|
||||
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
|
||||
.load::<(bool, bool, bool)>(conn)
|
||||
.expect("Error getting group access restrictions")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
match self.get_access_restrictions(user_uuid, None, conn).await {
|
||||
Some((read_only, _hide_passwords)) => !read_only,
|
||||
Some((read_only, _hide_passwords, manage)) => !read_only || manage,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_accessible_to_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
self.get_access_restrictions(user_uuid, None, conn).await.is_some()
|
||||
}
|
||||
|
||||
// Returns whether this cipher is a favorite of the specified user.
|
||||
pub async fn is_favorite(&self, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_favorite(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
Favorite::is_favorite(&self.uuid, user_uuid, conn).await
|
||||
}
|
||||
|
||||
// Sets whether this cipher is a favorite of the specified user.
|
||||
pub async fn set_favorite(&self, favorite: Option<bool>, user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn set_favorite(&self, favorite: Option<bool>, user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
match favorite {
|
||||
None => Ok(()), // No change requested.
|
||||
Some(status) => Favorite::set_favorite(status, &self.uuid, user_uuid, conn).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_folder_uuid(&self, user_uuid: &str, conn: &mut DbConn) -> Option<String> {
|
||||
pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &mut DbConn) -> Option<FolderId> {
|
||||
db_run! {conn: {
|
||||
folders_ciphers::table
|
||||
.inner_join(folders::table)
|
||||
.filter(folders::user_uuid.eq(&user_uuid))
|
||||
.filter(folders_ciphers::cipher_uuid.eq(&self.uuid))
|
||||
.select(folders_ciphers::folder_uuid)
|
||||
.first::<String>(conn)
|
||||
.first::<FolderId>(conn)
|
||||
.ok()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &CipherId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(ciphers::uuid.eq(uuid))
|
||||
@@ -620,6 +735,21 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_org(
|
||||
cipher_uuid: &CipherId,
|
||||
org_uuid: &OrganizationId,
|
||||
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
|
||||
@@ -632,7 +762,7 @@ impl Cipher {
|
||||
// true, then the non-interesting ciphers will not be returned. As a
|
||||
// result, those ciphers will not appear in "My Vault" for the org
|
||||
// owner/admin, but they can still be accessed via the org vault view.
|
||||
pub async fn find_by_user(user_uuid: &str, visible_only: bool, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, visible_only: bool, conn: &mut DbConn) -> Vec<Self> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! {conn: {
|
||||
let mut query = ciphers::table
|
||||
@@ -642,7 +772,7 @@ impl Cipher {
|
||||
.left_join(users_organizations::table.on(
|
||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||
.and(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
|
||||
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
@@ -669,7 +799,7 @@ impl Cipher {
|
||||
|
||||
if !visible_only {
|
||||
query = query.or_filter(
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin/owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner
|
||||
);
|
||||
}
|
||||
|
||||
@@ -687,7 +817,7 @@ impl Cipher {
|
||||
.left_join(users_organizations::table.on(
|
||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
|
||||
.and(users_organizations::user_uuid.eq(user_uuid))
|
||||
.and(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
|
||||
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
|
||||
@@ -701,7 +831,7 @@ impl Cipher {
|
||||
|
||||
if !visible_only {
|
||||
query = query.or_filter(
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin/owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner
|
||||
);
|
||||
}
|
||||
|
||||
@@ -714,12 +844,12 @@ impl Cipher {
|
||||
}
|
||||
|
||||
// Find all ciphers visible to the specified user.
|
||||
pub async fn find_by_user_visible(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user_visible(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
Self::find_by_user(user_uuid, true, conn).await
|
||||
}
|
||||
|
||||
// Find all ciphers directly owned by the specified user.
|
||||
pub async fn find_owned_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_owned_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(
|
||||
@@ -730,7 +860,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_owned_by_user(user_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_owned_by_user(user_uuid: &UserId, conn: &mut DbConn) -> i64 {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
@@ -741,7 +871,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
@@ -749,7 +879,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! {conn: {
|
||||
ciphers::table
|
||||
.filter(ciphers::organization_uuid.eq(org_uuid))
|
||||
@@ -760,7 +890,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_folder(folder_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_folder(folder_uuid: &FolderId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
folders_ciphers::table.inner_join(ciphers::table)
|
||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||
@@ -778,7 +908,7 @@ impl Cipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn get_collections(&self, user_id: String, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn get_collections(&self, user_uuid: UserId, conn: &mut DbConn) -> Vec<CollectionId> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! {conn: {
|
||||
ciphers_collections::table
|
||||
@@ -788,11 +918,11 @@ impl Cipher {
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(collections::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_id.clone()))
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_id.clone()))
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
@@ -803,14 +933,14 @@ impl Cipher {
|
||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||
))
|
||||
.filter(users_organizations::access_all.eq(true) // User has access all
|
||||
.or(users_collections::user_uuid.eq(user_id) // User has access to collection
|
||||
.or(users_collections::user_uuid.eq(user_uuid) // User has access to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(groups::access_all.eq(true)) // Access via groups
|
||||
.or(collections_groups::collections_uuid.is_not_null() // Access via groups
|
||||
.and(collections_groups::read_only.eq(false)))
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<String>(conn).unwrap_or_default()
|
||||
.load::<CollectionId>(conn).unwrap_or_default()
|
||||
}}
|
||||
} else {
|
||||
db_run! {conn: {
|
||||
@@ -821,23 +951,23 @@ impl Cipher {
|
||||
))
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(collections::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_id.clone()))
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_id.clone()))
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.filter(users_organizations::access_all.eq(true) // User has access all
|
||||
.or(users_collections::user_uuid.eq(user_id) // User has access to collection
|
||||
.or(users_collections::user_uuid.eq(user_uuid) // User has access to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<String>(conn).unwrap_or_default()
|
||||
.load::<CollectionId>(conn).unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_admin_collections(&self, user_id: String, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &mut DbConn) -> Vec<CollectionId> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! {conn: {
|
||||
ciphers_collections::table
|
||||
@@ -847,11 +977,11 @@ impl Cipher {
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(collections::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_id.clone()))
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_id.clone()))
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
@@ -862,15 +992,15 @@ impl Cipher {
|
||||
.and(collections_groups::groups_uuid.eq(groups::uuid))
|
||||
))
|
||||
.filter(users_organizations::access_all.eq(true) // User has access all
|
||||
.or(users_collections::user_uuid.eq(user_id) // User has access to collection
|
||||
.or(users_collections::user_uuid.eq(user_uuid) // User has access to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(groups::access_all.eq(true)) // Access via groups
|
||||
.or(collections_groups::collections_uuid.is_not_null() // Access via groups
|
||||
.and(collections_groups::read_only.eq(false)))
|
||||
.or(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner
|
||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<String>(conn).unwrap_or_default()
|
||||
.load::<CollectionId>(conn).unwrap_or_default()
|
||||
}}
|
||||
} else {
|
||||
db_run! {conn: {
|
||||
@@ -881,26 +1011,29 @@ impl Cipher {
|
||||
))
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(collections::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_id.clone()))
|
||||
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid)
|
||||
.and(users_collections::user_uuid.eq(user_id.clone()))
|
||||
.and(users_collections::user_uuid.eq(user_uuid.clone()))
|
||||
))
|
||||
.filter(users_organizations::access_all.eq(true) // User has access all
|
||||
.or(users_collections::user_uuid.eq(user_id) // User has access to collection
|
||||
.or(users_collections::user_uuid.eq(user_uuid) // User has access to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
.or(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner
|
||||
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||
)
|
||||
.select(ciphers_collections::collection_uuid)
|
||||
.load::<String>(conn).unwrap_or_default()
|
||||
.load::<CollectionId>(conn).unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a Vec with (cipher_uuid, collection_uuid)
|
||||
/// This is used during a full sync so we only need one query for all collections accessible.
|
||||
pub async fn get_collections_with_cipher_by_user(user_id: String, conn: &mut DbConn) -> Vec<(String, String)> {
|
||||
pub async fn get_collections_with_cipher_by_user(
|
||||
user_uuid: UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<(CipherId, CollectionId)> {
|
||||
db_run! {conn: {
|
||||
ciphers_collections::table
|
||||
.inner_join(collections::table.on(
|
||||
@@ -908,12 +1041,12 @@ impl Cipher {
|
||||
))
|
||||
.inner_join(users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(collections::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_id.clone())
|
||||
users_organizations::user_uuid.eq(user_uuid.clone())
|
||||
)
|
||||
))
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid).and(
|
||||
users_collections::user_uuid.eq(user_id.clone())
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
@@ -927,14 +1060,32 @@ impl Cipher {
|
||||
collections_groups::groups_uuid.eq(groups::uuid)
|
||||
)
|
||||
))
|
||||
.or_filter(users_collections::user_uuid.eq(user_id)) // User has access to collection
|
||||
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
|
||||
.or_filter(users_organizations::access_all.eq(true)) // User has access all
|
||||
.or_filter(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner
|
||||
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
|
||||
.or_filter(groups::access_all.eq(true)) //Access via group
|
||||
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
|
||||
.select(ciphers_collections::all_columns)
|
||||
.distinct()
|
||||
.load::<(String, String)>(conn).unwrap_or_default()
|
||||
.load::<(CipherId, CollectionId)>(conn).unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct CipherId(String);
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CollectionGroup, GroupUser, User, UserOrgStatus, UserOrgType, UserOrganization};
|
||||
use super::{
|
||||
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
|
||||
User, UserId,
|
||||
};
|
||||
use crate::CONFIG;
|
||||
use macros::UuidFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = collections)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Collection {
|
||||
pub uuid: String,
|
||||
pub org_uuid: String,
|
||||
pub uuid: CollectionId,
|
||||
pub org_uuid: OrganizationId,
|
||||
pub name: String,
|
||||
pub external_id: Option<String>,
|
||||
}
|
||||
@@ -18,26 +24,27 @@ db_object! {
|
||||
#[diesel(table_name = users_collections)]
|
||||
#[diesel(primary_key(user_uuid, collection_uuid))]
|
||||
pub struct CollectionUser {
|
||||
pub user_uuid: String,
|
||||
pub collection_uuid: String,
|
||||
pub user_uuid: UserId,
|
||||
pub collection_uuid: CollectionId,
|
||||
pub read_only: bool,
|
||||
pub hide_passwords: bool,
|
||||
pub manage: bool,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = ciphers_collections)]
|
||||
#[diesel(primary_key(cipher_uuid, collection_uuid))]
|
||||
pub struct CollectionCipher {
|
||||
pub cipher_uuid: String,
|
||||
pub collection_uuid: String,
|
||||
pub cipher_uuid: CipherId,
|
||||
pub collection_uuid: CollectionId,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Collection {
|
||||
pub fn new(org_uuid: String, name: String, external_id: Option<String>) -> Self {
|
||||
pub fn new(org_uuid: OrganizationId, name: String, external_id: Option<String>) -> Self {
|
||||
let mut new_model = Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: CollectionId(crate::util::get_uuid()),
|
||||
org_uuid,
|
||||
name,
|
||||
external_id: None,
|
||||
@@ -74,42 +81,65 @@ impl Collection {
|
||||
|
||||
pub async fn to_json_details(
|
||||
&self,
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
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 {
|
||||
match cipher_sync_data.user_organizations.get(&self.org_uuid) {
|
||||
Some(uo) if uo.has_full_access() => (false, false),
|
||||
Some(_) => {
|
||||
if let Some(uc) = cipher_sync_data.user_collections.get(&self.uuid) {
|
||||
(uc.read_only, uc.hide_passwords)
|
||||
let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||
match cipher_sync_data.members.get(&self.org_uuid) {
|
||||
// Only for Manager types Bitwarden returns true for the manage option
|
||||
// Owners and Admins always have true. Users are not able to have full access
|
||||
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
|
||||
Some(m) => {
|
||||
// Only let a manager manage collections when the have full read/write access
|
||||
let is_manager = m.atype == MembershipType::Manager;
|
||||
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
|
||||
(
|
||||
cu.read_only,
|
||||
cu.hide_passwords,
|
||||
cu.manage || (is_manager && !cu.read_only && !cu.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,
|
||||
cg.manage || (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 Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await {
|
||||
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
|
||||
Some(_) if self.is_manageable_by_user(user_uuid, conn).await => (false, false, true),
|
||||
Some(m) => {
|
||||
let is_manager = m.atype == MembershipType::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)
|
||||
}
|
||||
_ => (true, true, 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!(manage);
|
||||
json_object
|
||||
}
|
||||
|
||||
pub async fn can_access_collection(org_user: &UserOrganization, col_id: &str, conn: &mut DbConn) -> bool {
|
||||
org_user.has_status(UserOrgStatus::Confirmed)
|
||||
&& (org_user.has_full_access()
|
||||
|| CollectionUser::has_access_to_collection_by_user(col_id, &org_user.user_uuid, conn).await
|
||||
pub async fn can_access_collection(member: &Membership, col_id: &CollectionId, conn: &mut DbConn) -> bool {
|
||||
member.has_status(MembershipStatus::Confirmed)
|
||||
&& (member.has_full_access()
|
||||
|| CollectionUser::has_access_to_collection_by_user(col_id, &member.user_uuid, conn).await
|
||||
|| (CONFIG.org_groups_enabled()
|
||||
&& (GroupUser::has_full_access_by_member(&org_user.org_uuid, &org_user.uuid, conn).await
|
||||
|| GroupUser::has_access_to_collection_by_member(col_id, &org_user.uuid, conn).await)))
|
||||
&& (GroupUser::has_full_access_by_member(&member.org_uuid, &member.uuid, conn).await
|
||||
|| GroupUser::has_access_to_collection_by_member(col_id, &member.uuid, conn).await)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +197,7 @@ impl Collection {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> EmptyResult {
|
||||
for collection in Self::find_by_organization(org_uuid, conn).await {
|
||||
collection.delete(conn).await?;
|
||||
}
|
||||
@@ -175,12 +205,12 @@ impl Collection {
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(&self, conn: &mut DbConn) {
|
||||
for user_org in UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() {
|
||||
User::update_uuid_revision(&user_org.user_uuid, conn).await;
|
||||
for member in Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() {
|
||||
User::update_uuid_revision(&member.user_uuid, conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &CollectionId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
@@ -190,7 +220,7 @@ impl Collection {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user_uuid(user_uuid: String, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user_uuid(user_uuid: UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
@@ -216,7 +246,7 @@ impl Collection {
|
||||
)
|
||||
))
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(
|
||||
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
|
||||
@@ -247,7 +277,7 @@ impl Collection {
|
||||
)
|
||||
))
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(
|
||||
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
|
||||
@@ -261,15 +291,19 @@ impl Collection {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_organization_and_user_uuid(
|
||||
org_uuid: &OrganizationId,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
Self::find_by_user_uuid(user_uuid.to_owned(), conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|c| c.org_uuid == org_uuid)
|
||||
.filter(|c| &c.org_uuid == org_uuid)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
@@ -279,7 +313,7 @@ impl Collection {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
@@ -290,7 +324,11 @@ impl Collection {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_org(
|
||||
uuid: &CollectionId,
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.filter(collections::uuid.eq(uuid))
|
||||
@@ -302,7 +340,7 @@ impl Collection {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: String, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &mut DbConn) -> Option<Self> {
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
@@ -331,7 +369,7 @@ impl Collection {
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
@@ -360,7 +398,7 @@ impl Collection {
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
))
|
||||
).select(collections::all_columns)
|
||||
.first::<CollectionDb>(conn).ok()
|
||||
@@ -369,7 +407,7 @@ impl Collection {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_writable_by_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
if CONFIG.org_groups_enabled() {
|
||||
db_run! { conn: {
|
||||
@@ -393,7 +431,7 @@ impl Collection {
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid)
|
||||
.and(collections_groups::collections_uuid.eq(collections::uuid))
|
||||
))
|
||||
.filter(users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner
|
||||
.filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
@@ -418,7 +456,7 @@ impl Collection {
|
||||
users_collections::collection_uuid.eq(collections::uuid)
|
||||
.and(users_collections::user_uuid.eq(user_uuid))
|
||||
))
|
||||
.filter(users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner
|
||||
.filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
.or(users_organizations::access_all.eq(true)) // access_all via membership
|
||||
.or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection
|
||||
.and(users_collections::read_only.eq(false)))
|
||||
@@ -431,7 +469,7 @@ impl Collection {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hide_passwords_for_user(&self, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn hide_passwords_for_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
@@ -460,7 +498,7 @@ impl Collection {
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(&self.uuid).and(users_collections::hide_passwords.eq(true)).or(// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(UserOrgType::Admin as i32) // Org admin or owner
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
@@ -476,11 +514,61 @@ impl Collection {
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
let user_uuid = user_uuid.to_string();
|
||||
db_run! { conn: {
|
||||
collections::table
|
||||
.left_join(users_collections::table.on(
|
||||
users_collections::collection_uuid.eq(collections::uuid).and(
|
||||
users_collections::user_uuid.eq(user_uuid.clone())
|
||||
)
|
||||
))
|
||||
.left_join(users_organizations::table.on(
|
||||
collections::org_uuid.eq(users_organizations::org_uuid).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
)
|
||||
))
|
||||
.left_join(groups_users::table.on(
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
|
||||
))
|
||||
.left_join(groups::table.on(
|
||||
groups::uuid.eq(groups_users::groups_uuid)
|
||||
))
|
||||
.left_join(collections_groups::table.on(
|
||||
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
|
||||
collections_groups::collections_uuid.eq(collections::uuid)
|
||||
)
|
||||
))
|
||||
.filter(collections::uuid.eq(&self.uuid))
|
||||
.filter(
|
||||
users_collections::collection_uuid.eq(&self.uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection
|
||||
users_organizations::access_all.eq(true).or( // access_all in Organization
|
||||
users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner
|
||||
)).or(
|
||||
groups::access_all.eq(true) // access_all in groups
|
||||
).or( // access via groups
|
||||
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
|
||||
collections_groups::collections_uuid.is_not_null().and(
|
||||
collections_groups::manage.eq(true))
|
||||
)
|
||||
)
|
||||
)
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
.unwrap_or(0) != 0
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
impl CollectionUser {
|
||||
pub async fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_organization_and_user_uuid(
|
||||
org_uuid: &OrganizationId,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
users_collections::table
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
@@ -493,24 +581,30 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_organization(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
pub async fn find_by_organization_swap_user_uuid_with_member_uuid(
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<CollectionMembership> {
|
||||
let col_users = db_run! { conn: {
|
||||
users_collections::table
|
||||
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
|
||||
.filter(collections::org_uuid.eq(org_uuid))
|
||||
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<CollectionUserDb>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
.from_db()
|
||||
}}
|
||||
}};
|
||||
col_users.into_iter().map(|c| c.into()).collect()
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
user_uuid: &str,
|
||||
collection_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
collection_uuid: &CollectionId,
|
||||
read_only: bool,
|
||||
hide_passwords: bool,
|
||||
manage: bool,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
User::update_uuid_revision(user_uuid, conn).await;
|
||||
@@ -523,6 +617,7 @@ impl CollectionUser {
|
||||
users_collections::collection_uuid.eq(collection_uuid),
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
users_collections::manage.eq(manage),
|
||||
))
|
||||
.execute(conn)
|
||||
{
|
||||
@@ -537,6 +632,7 @@ impl CollectionUser {
|
||||
users_collections::collection_uuid.eq(collection_uuid),
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
users_collections::manage.eq(manage),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error adding user to collection")
|
||||
@@ -551,12 +647,14 @@ impl CollectionUser {
|
||||
users_collections::collection_uuid.eq(collection_uuid),
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
users_collections::manage.eq(manage),
|
||||
))
|
||||
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
|
||||
.do_update()
|
||||
.set((
|
||||
users_collections::read_only.eq(read_only),
|
||||
users_collections::hide_passwords.eq(hide_passwords),
|
||||
users_collections::manage.eq(manage),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error adding user to collection")
|
||||
@@ -578,7 +676,7 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_collection(collection_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
@@ -589,24 +687,27 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_collection_swap_user_uuid_with_org_user_uuid(
|
||||
collection_uuid: &str,
|
||||
pub async fn find_by_org_and_coll_swap_user_uuid_with_member_uuid(
|
||||
org_uuid: &OrganizationId,
|
||||
collection_uuid: &CollectionId,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
) -> Vec<CollectionMembership> {
|
||||
let col_users = db_run! { conn: {
|
||||
users_collections::table
|
||||
.filter(users_collections::collection_uuid.eq(collection_uuid))
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid)))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords))
|
||||
.select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
|
||||
.load::<CollectionUserDb>(conn)
|
||||
.expect("Error loading users_collections")
|
||||
.from_db()
|
||||
}}
|
||||
}};
|
||||
col_users.into_iter().map(|c| c.into()).collect()
|
||||
}
|
||||
|
||||
pub async fn find_by_collection_and_user(
|
||||
collection_uuid: &str,
|
||||
user_uuid: &str,
|
||||
collection_uuid: &CollectionId,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
@@ -620,7 +721,7 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
users_collections::table
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
@@ -631,7 +732,7 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &mut DbConn) -> EmptyResult {
|
||||
for collection in CollectionUser::find_by_collection(collection_uuid, conn).await.iter() {
|
||||
User::update_uuid_revision(&collection.user_uuid, conn).await;
|
||||
}
|
||||
@@ -643,7 +744,11 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user_and_org(
|
||||
user_uuid: &UserId,
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
@@ -659,14 +764,18 @@ impl CollectionUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn has_access_to_collection_by_user(col_id: &str, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn has_access_to_collection_by_user(
|
||||
col_id: &CollectionId,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
impl CollectionCipher {
|
||||
pub async fn save(cipher_uuid: &str, collection_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn save(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &mut DbConn) -> EmptyResult {
|
||||
Self::update_users_revision(collection_uuid, conn).await;
|
||||
|
||||
db_run! { conn:
|
||||
@@ -696,7 +805,7 @@ impl CollectionCipher {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &mut DbConn) -> EmptyResult {
|
||||
Self::update_users_revision(collection_uuid, conn).await;
|
||||
|
||||
db_run! { conn: {
|
||||
@@ -710,7 +819,7 @@ impl CollectionCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
@@ -718,7 +827,7 @@ impl CollectionCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid)))
|
||||
.execute(conn)
|
||||
@@ -726,9 +835,63 @@ impl CollectionCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(collection_uuid: &str, conn: &mut DbConn) {
|
||||
pub async fn update_users_revision(collection_uuid: &CollectionId, conn: &mut DbConn) {
|
||||
if let Some(collection) = Collection::find_by_uuid(collection_uuid, conn).await {
|
||||
collection.update_users_revision(conn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Added in case we need the membership_uuid instead of the user_uuid
|
||||
pub struct CollectionMembership {
|
||||
pub membership_uuid: MembershipId,
|
||||
pub collection_uuid: CollectionId,
|
||||
pub read_only: bool,
|
||||
pub hide_passwords: bool,
|
||||
pub manage: bool,
|
||||
}
|
||||
|
||||
impl CollectionMembership {
|
||||
pub fn to_json_details_for_member(&self, membership_type: i32) -> Value {
|
||||
json!({
|
||||
"id": self.membership_uuid,
|
||||
"readOnly": self.read_only,
|
||||
"hidePasswords": self.hide_passwords,
|
||||
"manage": membership_type >= MembershipType::Admin
|
||||
|| self.manage
|
||||
|| (membership_type == MembershipType::Manager
|
||||
&& !self.read_only
|
||||
&& !self.hide_passwords),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CollectionUser> for CollectionMembership {
|
||||
fn from(c: CollectionUser) -> Self {
|
||||
Self {
|
||||
membership_uuid: c.user_uuid.to_string().into(),
|
||||
collection_uuid: c.collection_uuid,
|
||||
read_only: c.read_only,
|
||||
hide_passwords: c.hide_passwords,
|
||||
manage: c.manage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct CollectionId(String);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{crypto, CONFIG};
|
||||
use core::fmt;
|
||||
use super::{AuthRequest, UserId};
|
||||
use crate::{crypto, util::format_date, CONFIG};
|
||||
use macros::IdFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -9,26 +12,25 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid, user_uuid))]
|
||||
pub struct Device {
|
||||
pub uuid: String,
|
||||
pub uuid: DeviceId,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_uuid: String,
|
||||
pub user_uuid: UserId,
|
||||
|
||||
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>,
|
||||
|
||||
pub refresh_token: String,
|
||||
|
||||
pub twofactor_remember: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Device {
|
||||
pub fn new(uuid: String, user_uuid: String, name: String, atype: i32) -> Self {
|
||||
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
@@ -47,6 +49,18 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
"name": self.name,
|
||||
"type": self.atype,
|
||||
"identifier": self.push_uuid,
|
||||
"creationDate": format_date(&self.created_at),
|
||||
"isTrusted": false,
|
||||
"object":"device"
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||
use data_encoding::BASE64;
|
||||
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
|
||||
@@ -75,12 +89,12 @@ impl Device {
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// ---
|
||||
// fn arg: orgs: Vec<super::UserOrganization>,
|
||||
// fn arg: members: Vec<super::Membership>,
|
||||
// ---
|
||||
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgowner: Vec<_> = members.iter().filter(|m| m.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgadmin: Vec<_> = members.iter().filter(|m| m.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orguser: Vec<_> = members.iter().filter(|m| m.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgmanager: Vec<_> = members.iter().filter(|m| m.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
|
||||
@@ -121,8 +135,42 @@ impl Device {
|
||||
pub fn is_registered(&self) -> bool {
|
||||
self.push_uuid.is_some()
|
||||
}
|
||||
|
||||
pub fn is_cli(&self) -> bool {
|
||||
matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeviceWithAuthRequest {
|
||||
pub device: Device,
|
||||
pub pending_auth_request: Option<AuthRequest>,
|
||||
}
|
||||
|
||||
impl DeviceWithAuthRequest {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let auth_request = match &self.pending_auth_request {
|
||||
Some(auth_request) => auth_request.to_json_for_pending_device(),
|
||||
None => Value::Null,
|
||||
};
|
||||
json!({
|
||||
"id": self.device.uuid,
|
||||
"name": self.device.name,
|
||||
"type": self.device.atype,
|
||||
"identifier": self.device.push_uuid,
|
||||
"creationDate": format_date(&self.device.created_at),
|
||||
"devicePendingAuthRequest": auth_request,
|
||||
"isTrusted": false,
|
||||
"object": "device",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from(c: Device, a: Option<AuthRequest>) -> Self {
|
||||
Self {
|
||||
device: c,
|
||||
pending_auth_request: a,
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@@ -150,7 +198,7 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
@@ -158,7 +206,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
@@ -169,7 +217,17 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<DeviceWithAuthRequest> {
|
||||
let devices = Self::find_by_user(user_uuid, conn).await;
|
||||
let mut result = Vec::new();
|
||||
for device in devices {
|
||||
let auth_request = AuthRequest::find_by_user_and_requested_device(user_uuid, &device.uuid, conn).await;
|
||||
result.push(DeviceWithAuthRequest::from(device, auth_request));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
@@ -179,7 +237,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &DeviceId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
@@ -189,7 +247,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn clear_push_token_by_uuid(uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::update(devices::table)
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
@@ -208,7 +266,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_latest_active_by_user(user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
@@ -219,7 +277,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_push_devices_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
@@ -230,7 +288,7 @@ impl Device {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn check_user_has_push_device(user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
devices::table
|
||||
.filter(devices::user_uuid.eq(user_uuid))
|
||||
@@ -243,60 +301,60 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Display)]
|
||||
pub enum DeviceType {
|
||||
#[display("Android")]
|
||||
Android = 0,
|
||||
#[display("iOS")]
|
||||
Ios = 1,
|
||||
#[display("Chrome Extension")]
|
||||
ChromeExtension = 2,
|
||||
#[display("Firefox Extension")]
|
||||
FirefoxExtension = 3,
|
||||
#[display("Opera Extension")]
|
||||
OperaExtension = 4,
|
||||
#[display("Edge Extension")]
|
||||
EdgeExtension = 5,
|
||||
#[display("Windows")]
|
||||
WindowsDesktop = 6,
|
||||
#[display("macOS")]
|
||||
MacOsDesktop = 7,
|
||||
#[display("Linux")]
|
||||
LinuxDesktop = 8,
|
||||
#[display("Chrome")]
|
||||
ChromeBrowser = 9,
|
||||
#[display("Firefox")]
|
||||
FirefoxBrowser = 10,
|
||||
#[display("Opera")]
|
||||
OperaBrowser = 11,
|
||||
#[display("Edge")]
|
||||
EdgeBrowser = 12,
|
||||
#[display("Internet Explorer")]
|
||||
IEBrowser = 13,
|
||||
#[display("Unknown Browser")]
|
||||
UnknownBrowser = 14,
|
||||
#[display("Android")]
|
||||
AndroidAmazon = 15,
|
||||
#[display("UWP")]
|
||||
Uwp = 16,
|
||||
#[display("Safari")]
|
||||
SafariBrowser = 17,
|
||||
#[display("Vivaldi")]
|
||||
VivaldiBrowser = 18,
|
||||
#[display("Vivaldi Extension")]
|
||||
VivaldiExtension = 19,
|
||||
#[display("Safari Extension")]
|
||||
SafariExtension = 20,
|
||||
#[display("SDK")]
|
||||
Sdk = 21,
|
||||
#[display("Server")]
|
||||
Server = 22,
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DeviceType::Android => write!(f, "Android"),
|
||||
DeviceType::Ios => write!(f, "iOS"),
|
||||
DeviceType::ChromeExtension => write!(f, "Chrome Extension"),
|
||||
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::IEBrowser => write!(f, "Internet Explorer"),
|
||||
DeviceType::UnknownBrowser => write!(f, "Unknown Browser"),
|
||||
DeviceType::AndroidAmazon => write!(f, "Android Amazon"),
|
||||
DeviceType::Uwp => write!(f, "UWP"),
|
||||
DeviceType::SafariBrowser => write!(f, "Safari Browser"),
|
||||
DeviceType::VivaldiBrowser => write!(f, "Vivaldi Browser"),
|
||||
DeviceType::VivaldiExtension => write!(f, "Vivaldi Extension"),
|
||||
DeviceType::SafariExtension => write!(f, "Safari Extension"),
|
||||
DeviceType::Sdk => write!(f, "SDK"),
|
||||
DeviceType::Server => write!(f, "Server"),
|
||||
}
|
||||
}
|
||||
#[display("Windows CLI")]
|
||||
WindowsCLI = 23,
|
||||
#[display("macOS CLI")]
|
||||
MacOsCLI = 24,
|
||||
#[display("Linux CLI")]
|
||||
LinuxCLI = 25,
|
||||
}
|
||||
|
||||
impl DeviceType {
|
||||
@@ -325,7 +383,15 @@ impl DeviceType {
|
||||
20 => DeviceType::SafariExtension,
|
||||
21 => DeviceType::Sdk,
|
||||
22 => DeviceType::Server,
|
||||
23 => DeviceType::WindowsCLI,
|
||||
24 => DeviceType::MacOsCLI,
|
||||
25 => DeviceType::LinuxCLI,
|
||||
_ => DeviceType::UnknownBrowser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
|
||||
)]
|
||||
pub struct DeviceId(String);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{User, UserId};
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
|
||||
|
||||
use super::User;
|
||||
use macros::UuidFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -11,9 +12,9 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct EmergencyAccess {
|
||||
pub uuid: String,
|
||||
pub grantor_uuid: String,
|
||||
pub grantee_uuid: Option<String>,
|
||||
pub uuid: EmergencyAccessId,
|
||||
pub grantor_uuid: UserId,
|
||||
pub grantee_uuid: Option<UserId>,
|
||||
pub email: Option<String>,
|
||||
pub key_encrypted: Option<String>,
|
||||
pub atype: i32, //EmergencyAccessType
|
||||
@@ -26,14 +27,14 @@ 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 {
|
||||
pub fn new(grantor_uuid: UserId, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: EmergencyAccessId(crate::util::get_uuid()),
|
||||
grantor_uuid,
|
||||
grantee_uuid: None,
|
||||
email: Some(email),
|
||||
@@ -82,14 +83,14 @@ impl EmergencyAccess {
|
||||
}
|
||||
|
||||
pub async fn to_json_grantee_details(&self, conn: &mut DbConn) -> Option<Value> {
|
||||
let grantee_user = if let Some(grantee_uuid) = self.grantee_uuid.as_deref() {
|
||||
let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid {
|
||||
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
|
||||
} else if let Some(email) = self.email.as_deref() {
|
||||
match User::find_by_mail(email, conn).await {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +212,7 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
for ea in Self::find_all_by_grantor_uuid(user_uuid, conn).await {
|
||||
ea.delete(conn).await?;
|
||||
}
|
||||
@@ -239,8 +240,8 @@ impl EmergencyAccess {
|
||||
}
|
||||
|
||||
pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||
grantor_uuid: &str,
|
||||
grantee_uuid: &str,
|
||||
grantor_uuid: &UserId,
|
||||
grantee_uuid: &UserId,
|
||||
email: &str,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
@@ -262,7 +263,11 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantor_uuid(uuid: &str, grantor_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_grantor_uuid(
|
||||
uuid: &EmergencyAccessId,
|
||||
grantor_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
@@ -272,7 +277,11 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantee_uuid(uuid: &str, grantee_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_grantee_uuid(
|
||||
uuid: &EmergencyAccessId,
|
||||
grantee_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
@@ -282,7 +291,11 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid_and_grantee_email(uuid: &str, grantee_email: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_grantee_email(
|
||||
uuid: &EmergencyAccessId,
|
||||
grantee_email: &str,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
@@ -292,7 +305,7 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_all_by_grantee_uuid(grantee_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||
@@ -319,7 +332,7 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_all_by_grantor_uuid(grantor_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
@@ -327,7 +340,12 @@ impl EmergencyAccess {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn accept_invite(&mut self, grantee_uuid: &str, grantee_email: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn accept_invite(
|
||||
&mut self,
|
||||
grantee_uuid: &UserId,
|
||||
grantee_email: &str,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email {
|
||||
err!("User email does not match invite.");
|
||||
}
|
||||
@@ -337,10 +355,28 @@ impl EmergencyAccess {
|
||||
}
|
||||
|
||||
self.status = EmergencyAccessStatus::Accepted as i32;
|
||||
self.grantee_uuid = Some(String::from(grantee_uuid));
|
||||
self.grantee_uuid = Some(grantee_uuid.clone());
|
||||
self.email = None;
|
||||
self.save(conn).await
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct EmergencyAccessId(String);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::db::DbConn;
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
//use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{api::EmptyResult, error::MapResult, CONFIG};
|
||||
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId};
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG};
|
||||
|
||||
// https://bitwarden.com/help/event-logs/
|
||||
|
||||
@@ -13,22 +13,23 @@ db_object! {
|
||||
// Upstream SQL: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Sql/dbo/Tables/Event.sql
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = event)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Event {
|
||||
pub uuid: String,
|
||||
pub uuid: EventId,
|
||||
pub event_type: i32, // EventType
|
||||
pub user_uuid: Option<String>,
|
||||
pub org_uuid: Option<String>,
|
||||
pub cipher_uuid: Option<String>,
|
||||
pub collection_uuid: Option<String>,
|
||||
pub group_uuid: Option<String>,
|
||||
pub org_user_uuid: Option<String>,
|
||||
pub act_user_uuid: Option<String>,
|
||||
pub user_uuid: Option<UserId>,
|
||||
pub org_uuid: Option<OrganizationId>,
|
||||
pub cipher_uuid: Option<CipherId>,
|
||||
pub collection_uuid: Option<CollectionId>,
|
||||
pub group_uuid: Option<GroupId>,
|
||||
pub org_user_uuid: Option<MembershipId>,
|
||||
pub act_user_uuid: Option<UserId>,
|
||||
// Upstream enum: https://github.com/bitwarden/server/blob/8a22c0479e987e756ce7412c48a732f9002f0a2d/src/Core/Enums/DeviceType.cs
|
||||
pub device_type: Option<i32>,
|
||||
pub ip_address: Option<String>,
|
||||
pub event_date: NaiveDateTime,
|
||||
pub policy_uuid: Option<String>,
|
||||
pub policy_uuid: Option<OrgPolicyId>,
|
||||
pub provider_uuid: Option<String>,
|
||||
pub provider_user_uuid: Option<String>,
|
||||
pub provider_org_uuid: Option<String>,
|
||||
@@ -49,6 +50,8 @@ pub enum EventType {
|
||||
UserClientExportedVault = 1007,
|
||||
// UserUpdatedTempPassword = 1008, // Not supported
|
||||
// UserMigratedKeyToKeyConnector = 1009, // Not supported
|
||||
UserRequestedDeviceApproval = 1010,
|
||||
// UserTdeOffboardingPasswordSet = 1011, // Not supported
|
||||
|
||||
// Cipher
|
||||
CipherCreated = 1100,
|
||||
@@ -69,6 +72,7 @@ pub enum EventType {
|
||||
CipherSoftDeleted = 1115,
|
||||
CipherRestored = 1116,
|
||||
CipherClientToggledCardNumberVisible = 1117,
|
||||
CipherClientToggledTOTPSeedVisible = 1118,
|
||||
|
||||
// Collection
|
||||
CollectionCreated = 1300,
|
||||
@@ -94,6 +98,10 @@ pub enum EventType {
|
||||
// OrganizationUserFirstSsoLogin = 1510, // Not supported
|
||||
OrganizationUserRevoked = 1511,
|
||||
OrganizationUserRestored = 1512,
|
||||
OrganizationUserApprovedAuthRequest = 1513,
|
||||
OrganizationUserRejectedAuthRequest = 1514,
|
||||
OrganizationUserDeleted = 1515,
|
||||
OrganizationUserLeft = 1516,
|
||||
|
||||
// Organization
|
||||
OrganizationUpdated = 1600,
|
||||
@@ -105,6 +113,7 @@ pub enum EventType {
|
||||
// OrganizationEnabledKeyConnector = 1606, // Not supported
|
||||
// OrganizationDisabledKeyConnector = 1607, // Not supported
|
||||
// OrganizationSponsorshipsSynced = 1608, // Not supported
|
||||
// OrganizationCollectionManagementUpdated = 1609, // Not supported
|
||||
|
||||
// Policy
|
||||
PolicyUpdated = 1700,
|
||||
@@ -117,6 +126,13 @@ pub enum EventType {
|
||||
// ProviderOrganizationAdded = 1901, // Not supported
|
||||
// ProviderOrganizationRemoved = 1902, // Not supported
|
||||
// ProviderOrganizationVaultAccessed = 1903, // Not supported
|
||||
|
||||
// OrganizationDomainAdded = 2000, // Not supported
|
||||
// OrganizationDomainRemoved = 2001, // Not supported
|
||||
// OrganizationDomainVerified = 2002, // Not supported
|
||||
// OrganizationDomainNotVerified = 2003, // Not supported
|
||||
|
||||
// SecretRetrieved = 2100, // Not supported
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
@@ -128,7 +144,7 @@ impl Event {
|
||||
};
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: EventId(crate::util::get_uuid()),
|
||||
event_type,
|
||||
user_uuid: None,
|
||||
org_uuid: None,
|
||||
@@ -246,7 +262,7 @@ impl Event {
|
||||
/// ##############
|
||||
/// Custom Queries
|
||||
pub async fn find_by_organization_uuid(
|
||||
org_uuid: &str,
|
||||
org_uuid: &OrganizationId,
|
||||
start: &NaiveDateTime,
|
||||
end: &NaiveDateTime,
|
||||
conn: &mut DbConn,
|
||||
@@ -263,7 +279,7 @@ impl Event {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_org(org_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
event::table
|
||||
.filter(event::org_uuid.eq(org_uuid))
|
||||
@@ -274,16 +290,16 @@ impl Event {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_org_and_user_org(
|
||||
org_uuid: &str,
|
||||
user_org_uuid: &str,
|
||||
pub async fn find_by_org_and_member(
|
||||
org_uuid: &OrganizationId,
|
||||
member_uuid: &MembershipId,
|
||||
start: &NaiveDateTime,
|
||||
end: &NaiveDateTime,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
event::table
|
||||
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(user_org_uuid)))
|
||||
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid)))
|
||||
.filter(event::org_uuid.eq(org_uuid))
|
||||
.filter(event::event_date.between(start, end))
|
||||
.filter(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())))
|
||||
@@ -297,7 +313,7 @@ impl Event {
|
||||
}
|
||||
|
||||
pub async fn find_by_cipher_uuid(
|
||||
cipher_uuid: &str,
|
||||
cipher_uuid: &CipherId,
|
||||
start: &NaiveDateTime,
|
||||
end: &NaiveDateTime,
|
||||
conn: &mut DbConn,
|
||||
@@ -327,3 +343,6 @@ impl Event {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, DieselNewType, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EventId(String);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::User;
|
||||
use super::{CipherId, User, UserId};
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = favorites)]
|
||||
#[diesel(primary_key(user_uuid, cipher_uuid))]
|
||||
pub struct Favorite {
|
||||
pub user_uuid: String,
|
||||
pub cipher_uuid: String,
|
||||
pub user_uuid: UserId,
|
||||
pub cipher_uuid: CipherId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::error::MapResult;
|
||||
|
||||
impl Favorite {
|
||||
// Returns whether the specified cipher is a favorite of the specified user.
|
||||
pub async fn is_favorite(cipher_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
let query = favorites::table
|
||||
.filter(favorites::cipher_uuid.eq(cipher_uuid))
|
||||
@@ -29,7 +29,12 @@ impl Favorite {
|
||||
}
|
||||
|
||||
// Sets whether the specified cipher is a favorite of the specified user.
|
||||
pub async fn set_favorite(favorite: bool, cipher_uuid: &str, user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn set_favorite(
|
||||
favorite: bool,
|
||||
cipher_uuid: &CipherId,
|
||||
user_uuid: &UserId,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
let (old, new) = (Self::is_favorite(cipher_uuid, user_uuid, conn).await, favorite);
|
||||
match (old, new) {
|
||||
(false, true) => {
|
||||
@@ -62,7 +67,7 @@ impl Favorite {
|
||||
}
|
||||
|
||||
// Delete all favorite entries associated with the specified cipher.
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
@@ -71,7 +76,7 @@ impl Favorite {
|
||||
}
|
||||
|
||||
// Delete all favorite entries associated with the specified user.
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
@@ -81,12 +86,12 @@ impl Favorite {
|
||||
|
||||
/// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers
|
||||
/// This is used during a full sync so we only need one query for all favorite cipher matches.
|
||||
pub async fn get_all_cipher_uuid_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<CipherId> {
|
||||
db_run! { conn: {
|
||||
favorites::table
|
||||
.filter(favorites::user_uuid.eq(user_uuid))
|
||||
.select(favorites::cipher_uuid)
|
||||
.load::<String>(conn)
|
||||
.load::<CipherId>(conn)
|
||||
.unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::User;
|
||||
use super::{CipherId, User, UserId};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = folders)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Folder {
|
||||
pub uuid: String,
|
||||
pub uuid: FolderId,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub user_uuid: String,
|
||||
pub user_uuid: UserId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
@@ -19,18 +21,18 @@ db_object! {
|
||||
#[diesel(table_name = folders_ciphers)]
|
||||
#[diesel(primary_key(cipher_uuid, folder_uuid))]
|
||||
pub struct FolderCipher {
|
||||
pub cipher_uuid: String,
|
||||
pub folder_uuid: String,
|
||||
pub cipher_uuid: CipherId,
|
||||
pub folder_uuid: FolderId,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Folder {
|
||||
pub fn new(user_uuid: String, name: String) -> Self {
|
||||
pub fn new(user_uuid: UserId, name: String) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: FolderId(crate::util::get_uuid()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
@@ -52,10 +54,10 @@ impl Folder {
|
||||
}
|
||||
|
||||
impl FolderCipher {
|
||||
pub fn new(folder_uuid: &str, cipher_uuid: &str) -> Self {
|
||||
pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self {
|
||||
Self {
|
||||
folder_uuid: folder_uuid.to_string(),
|
||||
cipher_uuid: cipher_uuid.to_string(),
|
||||
folder_uuid,
|
||||
cipher_uuid,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,24 +115,25 @@ impl Folder {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
for folder in Self::find_by_user(user_uuid, conn).await {
|
||||
folder.delete(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, 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()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
folders::table
|
||||
.filter(folders::user_uuid.eq(user_uuid))
|
||||
@@ -176,7 +179,7 @@ impl FolderCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)))
|
||||
.execute(conn)
|
||||
@@ -184,7 +187,7 @@ impl FolderCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_folder(folder_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid)))
|
||||
.execute(conn)
|
||||
@@ -192,7 +195,11 @@ impl FolderCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_folder_and_cipher(folder_uuid: &str, cipher_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_folder_and_cipher(
|
||||
folder_uuid: &FolderId,
|
||||
cipher_uuid: &CipherId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
folders_ciphers::table
|
||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||
@@ -203,7 +210,7 @@ impl FolderCipher {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_folder(folder_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_folder(folder_uuid: &FolderId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
folders_ciphers::table
|
||||
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
|
||||
@@ -215,14 +222,32 @@ impl FolderCipher {
|
||||
|
||||
/// Return a vec with (cipher_uuid, folder_uuid)
|
||||
/// This is used during a full sync so we only need one query for all folder matches.
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<(String, String)> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<(CipherId, FolderId)> {
|
||||
db_run! { conn: {
|
||||
folders_ciphers::table
|
||||
.inner_join(folders::table)
|
||||
.filter(folders::user_uuid.eq(user_uuid))
|
||||
.select(folders_ciphers::all_columns)
|
||||
.load::<(String, String)>(conn)
|
||||
.load::<(CipherId, FolderId)>(conn)
|
||||
.unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct FolderId(String);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
|
||||
use crate::api::EmptyResult;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use macros::UuidFromParam;
|
||||
use serde_json::Value;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = groups)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Group {
|
||||
pub uuid: String,
|
||||
pub organizations_uuid: String,
|
||||
pub uuid: GroupId,
|
||||
pub organizations_uuid: OrganizationId,
|
||||
pub name: String,
|
||||
pub access_all: bool,
|
||||
pub external_id: Option<String>,
|
||||
@@ -19,28 +26,34 @@ db_object! {
|
||||
#[diesel(table_name = collections_groups)]
|
||||
#[diesel(primary_key(collections_uuid, groups_uuid))]
|
||||
pub struct CollectionGroup {
|
||||
pub collections_uuid: String,
|
||||
pub groups_uuid: String,
|
||||
pub collections_uuid: CollectionId,
|
||||
pub groups_uuid: GroupId,
|
||||
pub read_only: bool,
|
||||
pub hide_passwords: bool,
|
||||
pub manage: bool,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = groups_users)]
|
||||
#[diesel(primary_key(groups_uuid, users_organizations_uuid))]
|
||||
pub struct GroupUser {
|
||||
pub groups_uuid: String,
|
||||
pub users_organizations_uuid: String
|
||||
pub groups_uuid: GroupId,
|
||||
pub users_organizations_uuid: MembershipId
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Group {
|
||||
pub fn new(organizations_uuid: String, name: String, access_all: bool, external_id: Option<String>) -> Self {
|
||||
pub fn new(
|
||||
organizations_uuid: OrganizationId,
|
||||
name: String,
|
||||
access_all: bool,
|
||||
external_id: Option<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let mut new_model = Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: GroupId(crate::util::get_uuid()),
|
||||
organizations_uuid,
|
||||
name,
|
||||
access_all,
|
||||
@@ -70,6 +83,9 @@ impl Group {
|
||||
}
|
||||
|
||||
pub async fn to_json_details(&self, conn: &mut DbConn) -> Value {
|
||||
// If both read_only and hide_passwords are false, then manage should be true
|
||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||
// Or an entry with everything to false
|
||||
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn)
|
||||
.await
|
||||
.iter()
|
||||
@@ -77,7 +93,8 @@ impl Group {
|
||||
json!({
|
||||
"id": entry.collections_uuid,
|
||||
"readOnly": entry.read_only,
|
||||
"hidePasswords": entry.hide_passwords
|
||||
"hidePasswords": entry.hide_passwords,
|
||||
"manage": entry.manage,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -103,18 +120,38 @@ impl Group {
|
||||
}
|
||||
|
||||
impl CollectionGroup {
|
||||
pub fn new(collections_uuid: String, groups_uuid: String, read_only: bool, hide_passwords: bool) -> Self {
|
||||
pub fn new(
|
||||
collections_uuid: CollectionId,
|
||||
groups_uuid: GroupId,
|
||||
read_only: bool,
|
||||
hide_passwords: bool,
|
||||
manage: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
collections_uuid,
|
||||
groups_uuid,
|
||||
read_only,
|
||||
hide_passwords,
|
||||
manage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json_details_for_group(&self) -> Value {
|
||||
// If both read_only and hide_passwords are false, then manage should be true
|
||||
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||
// Or an entry with everything to false
|
||||
// For backwards compaibility and migration proposes we keep checking read_only and hide_password
|
||||
json!({
|
||||
"id": self.groups_uuid,
|
||||
"readOnly": self.read_only,
|
||||
"hidePasswords": self.hide_passwords,
|
||||
"manage": self.manage || (!self.read_only && !self.hide_passwords),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GroupUser {
|
||||
pub fn new(groups_uuid: String, users_organizations_uuid: String) -> Self {
|
||||
pub fn new(groups_uuid: GroupId, users_organizations_uuid: MembershipId) -> Self {
|
||||
Self {
|
||||
groups_uuid,
|
||||
users_organizations_uuid,
|
||||
@@ -122,13 +159,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 {
|
||||
@@ -165,27 +195,27 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> EmptyResult {
|
||||
for group in Self::find_by_organization(org_uuid, conn).await {
|
||||
group.delete(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_organization(organizations_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
groups::table
|
||||
.filter(groups::organizations_uuid.eq(organizations_uuid))
|
||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||
.load::<GroupDb>(conn)
|
||||
.expect("Error loading groups")
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn count_by_org(organizations_uuid: &str, conn: &mut DbConn) -> i64 {
|
||||
pub async fn count_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> i64 {
|
||||
db_run! { conn: {
|
||||
groups::table
|
||||
.filter(groups::organizations_uuid.eq(organizations_uuid))
|
||||
.filter(groups::organizations_uuid.eq(org_uuid))
|
||||
.count()
|
||||
.first::<i64>(conn)
|
||||
.ok()
|
||||
@@ -193,17 +223,22 @@ impl Group {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, 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()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_external_id_and_org(external_id: &str, org_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_external_id_and_org(
|
||||
external_id: &str,
|
||||
org_uuid: &OrganizationId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
groups::table
|
||||
.filter(groups::external_id.eq(external_id))
|
||||
@@ -214,7 +249,7 @@ impl Group {
|
||||
}}
|
||||
}
|
||||
//Returns all organizations the user has full access to
|
||||
pub async fn gather_user_organizations_full_access(user_uuid: &str, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &mut DbConn) -> Vec<OrganizationId> {
|
||||
db_run! { conn: {
|
||||
groups_users::table
|
||||
.inner_join(users_organizations::table.on(
|
||||
@@ -227,12 +262,12 @@ impl Group {
|
||||
.filter(groups::access_all.eq(true))
|
||||
.select(groups::organizations_uuid)
|
||||
.distinct()
|
||||
.load::<String>(conn)
|
||||
.load::<OrganizationId>(conn)
|
||||
.expect("Error loading organization group full access information for user")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn is_in_full_access_group(user_uuid: &str, org_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &mut DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
groups::table
|
||||
.inner_join(groups_users::table.on(
|
||||
@@ -261,13 +296,13 @@ impl Group {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn update_revision(uuid: &str, conn: &mut DbConn) {
|
||||
pub async fn update_revision(uuid: &GroupId, conn: &mut DbConn) {
|
||||
if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await {
|
||||
warn!("Failed to update revision for {}: {:#?}", uuid, e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult {
|
||||
async fn _update_revision(uuid: &GroupId, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! {conn: {
|
||||
crate::util::retry(|| {
|
||||
diesel::update(groups::table.filter(groups::uuid.eq(uuid)))
|
||||
@@ -294,6 +329,7 @@ impl CollectionGroup {
|
||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
||||
collections_groups::read_only.eq(&self.read_only),
|
||||
collections_groups::hide_passwords.eq(&self.hide_passwords),
|
||||
collections_groups::manage.eq(&self.manage),
|
||||
))
|
||||
.execute(conn)
|
||||
{
|
||||
@@ -308,6 +344,7 @@ impl CollectionGroup {
|
||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
||||
collections_groups::read_only.eq(&self.read_only),
|
||||
collections_groups::hide_passwords.eq(&self.hide_passwords),
|
||||
collections_groups::manage.eq(&self.manage),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error adding group to collection")
|
||||
@@ -322,12 +359,14 @@ impl CollectionGroup {
|
||||
collections_groups::groups_uuid.eq(&self.groups_uuid),
|
||||
collections_groups::read_only.eq(self.read_only),
|
||||
collections_groups::hide_passwords.eq(self.hide_passwords),
|
||||
collections_groups::manage.eq(self.manage),
|
||||
))
|
||||
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
|
||||
.do_update()
|
||||
.set((
|
||||
collections_groups::read_only.eq(self.read_only),
|
||||
collections_groups::hide_passwords.eq(self.hide_passwords),
|
||||
collections_groups::manage.eq(self.manage),
|
||||
))
|
||||
.execute(conn)
|
||||
.map_res("Error adding group to collection")
|
||||
@@ -335,7 +374,7 @@ impl CollectionGroup {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_group(group_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
collections_groups::table
|
||||
.filter(collections_groups::groups_uuid.eq(group_uuid))
|
||||
@@ -345,7 +384,7 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
collections_groups::table
|
||||
.inner_join(groups_users::table.on(
|
||||
@@ -362,7 +401,7 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_collection(collection_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
collections_groups::table
|
||||
.filter(collections_groups::collections_uuid.eq(collection_uuid))
|
||||
@@ -388,7 +427,7 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_group(group_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &mut DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
@@ -402,7 +441,7 @@ impl CollectionGroup {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_collection(collection_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &mut DbConn) -> EmptyResult {
|
||||
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
|
||||
for collection_assigned_to_group in collection_assigned_to_groups {
|
||||
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await;
|
||||
@@ -467,7 +506,7 @@ impl GroupUser {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_group(group_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_group(group_uuid: &GroupId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
groups_users::table
|
||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||
@@ -477,10 +516,10 @@ impl GroupUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(users_organizations_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_member(member_uuid: &MembershipId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
groups_users::table
|
||||
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
|
||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||
.load::<GroupUserDb>(conn)
|
||||
.expect("Error loading groups for user")
|
||||
.from_db()
|
||||
@@ -488,8 +527,8 @@ impl GroupUser {
|
||||
}
|
||||
|
||||
pub async fn has_access_to_collection_by_member(
|
||||
collection_uuid: &str,
|
||||
member_uuid: &str,
|
||||
collection_uuid: &CollectionId,
|
||||
member_uuid: &MembershipId,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
db_run! { conn: {
|
||||
@@ -505,7 +544,11 @@ impl GroupUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn has_full_access_by_member(org_uuid: &str, member_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn has_full_access_by_member(
|
||||
org_uuid: &OrganizationId,
|
||||
member_uuid: &MembershipId,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
db_run! { conn: {
|
||||
groups_users::table
|
||||
.inner_join(groups::table.on(
|
||||
@@ -521,32 +564,32 @@ impl GroupUser {
|
||||
}
|
||||
|
||||
pub async fn update_user_revision(&self, conn: &mut DbConn) {
|
||||
match UserOrganization::find_by_uuid(&self.users_organizations_uuid, conn).await {
|
||||
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
|
||||
None => warn!("User could not be found!"),
|
||||
match Membership::find_by_uuid(&self.users_organizations_uuid, conn).await {
|
||||
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
|
||||
None => warn!("Member could not be found!"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_by_group_id_and_user_id(
|
||||
group_uuid: &str,
|
||||
users_organizations_uuid: &str,
|
||||
pub async fn delete_by_group_and_member(
|
||||
group_uuid: &GroupId,
|
||||
member_uuid: &MembershipId,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await {
|
||||
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
|
||||
None => warn!("User could not be found!"),
|
||||
match Membership::find_by_uuid(member_uuid, conn).await {
|
||||
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
|
||||
None => warn!("Member could not be found!"),
|
||||
};
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(groups_users::table)
|
||||
.filter(groups_users::groups_uuid.eq(group_uuid))
|
||||
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
|
||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting group users")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_group(group_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &mut DbConn) -> EmptyResult {
|
||||
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
|
||||
for group_user in group_users {
|
||||
group_user.update_user_revision(conn).await;
|
||||
@@ -560,17 +603,35 @@ impl GroupUser {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(users_organizations_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await {
|
||||
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
|
||||
None => warn!("User could not be found!"),
|
||||
pub async fn delete_all_by_member(member_uuid: &MembershipId, conn: &mut DbConn) -> EmptyResult {
|
||||
match Membership::find_by_uuid(member_uuid, conn).await {
|
||||
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
|
||||
None => warn!("Member could not be found!"),
|
||||
}
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(groups_users::table)
|
||||
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
|
||||
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting user groups")
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct GroupId(String);
|
||||
|
||||
@@ -12,22 +12,30 @@ 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::collection::{Collection, CollectionCipher, CollectionUser};
|
||||
pub use self::device::{Device, DeviceType};
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType};
|
||||
pub use self::attachment::{Attachment, AttachmentId};
|
||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||
pub use self::device::{Device, DeviceId, DeviceType};
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
||||
pub use self::event::{Event, EventType};
|
||||
pub use self::favorite::Favorite;
|
||||
pub use self::folder::{Folder, FolderCipher};
|
||||
pub use self::group::{CollectionGroup, Group, GroupUser};
|
||||
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::folder::{Folder, FolderCipher, FolderId};
|
||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||
pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyId, OrgPolicyType};
|
||||
pub use self::organization::{
|
||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||
OrganizationId,
|
||||
};
|
||||
pub use self::send::{
|
||||
id::{SendFileId, SendId},
|
||||
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};
|
||||
pub use self::user::{Invitation, User, UserId, UserKdfType, UserStampException};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use derive_more::{AsRef, From};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -5,22 +6,22 @@ use crate::api::EmptyResult;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
|
||||
use super::{TwoFactor, UserOrgStatus, UserOrgType, UserOrganization};
|
||||
use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId};
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = org_policies)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct OrgPolicy {
|
||||
pub uuid: String,
|
||||
pub org_uuid: String,
|
||||
pub uuid: OrgPolicyId,
|
||||
pub org_uuid: OrganizationId,
|
||||
pub atype: i32,
|
||||
pub enabled: bool,
|
||||
pub data: String,
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/PolicyType.cs
|
||||
// https://github.com/bitwarden/server/blob/abfdf6f5cb0f1f1504dbaaaa0e04ce9cb60faf19/src/Core/AdminConsole/Enums/PolicyType.cs
|
||||
#[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)]
|
||||
pub enum OrgPolicyType {
|
||||
TwoFactorAuthentication = 0,
|
||||
@@ -34,6 +35,10 @@ pub enum OrgPolicyType {
|
||||
ResetPassword = 8,
|
||||
// MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed)
|
||||
// DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed)
|
||||
// ActivateAutofill = 11,
|
||||
// AutomaticAppLogIn = 12,
|
||||
// FreeFamiliesSponsorshipPolicy = 13,
|
||||
RemoveUnlockWithPin = 14,
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs
|
||||
@@ -62,9 +67,9 @@ pub enum OrgPolicyErr {
|
||||
|
||||
/// Local methods
|
||||
impl OrgPolicy {
|
||||
pub fn new(org_uuid: String, atype: OrgPolicyType, data: String) -> Self {
|
||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, data: String) -> Self {
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: OrgPolicyId(crate::util::get_uuid()),
|
||||
org_uuid,
|
||||
atype: atype as i32,
|
||||
enabled: false,
|
||||
@@ -142,17 +147,7 @@ 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> {
|
||||
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
org_policies::table
|
||||
.filter(org_policies::org_uuid.eq(org_uuid))
|
||||
@@ -162,7 +157,7 @@ impl OrgPolicy {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_confirmed_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
org_policies::table
|
||||
.inner_join(
|
||||
@@ -171,7 +166,7 @@ impl OrgPolicy {
|
||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
||||
)
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.select(org_policies::all_columns)
|
||||
.load::<OrgPolicyDb>(conn)
|
||||
@@ -180,7 +175,11 @@ impl OrgPolicy {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_org_and_type(org_uuid: &str, policy_type: OrgPolicyType, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_org_and_type(
|
||||
org_uuid: &OrganizationId,
|
||||
policy_type: OrgPolicyType,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
org_policies::table
|
||||
.filter(org_policies::org_uuid.eq(org_uuid))
|
||||
@@ -191,7 +190,7 @@ impl OrgPolicy {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_organization(org_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid)))
|
||||
.execute(conn)
|
||||
@@ -200,7 +199,7 @@ impl OrgPolicy {
|
||||
}
|
||||
|
||||
pub async fn find_accepted_and_confirmed_by_user_and_active_policy(
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
policy_type: OrgPolicyType,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
@@ -212,10 +211,10 @@ impl OrgPolicy {
|
||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
||||
)
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Accepted as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Accepted as i32)
|
||||
)
|
||||
.or_filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(org_policies::atype.eq(policy_type as i32))
|
||||
.filter(org_policies::enabled.eq(true))
|
||||
@@ -227,7 +226,7 @@ impl OrgPolicy {
|
||||
}
|
||||
|
||||
pub async fn find_confirmed_by_user_and_active_policy(
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
policy_type: OrgPolicyType,
|
||||
conn: &mut DbConn,
|
||||
) -> Vec<Self> {
|
||||
@@ -239,7 +238,7 @@ impl OrgPolicy {
|
||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
||||
)
|
||||
.filter(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
users_organizations::status.eq(MembershipStatus::Confirmed as i32)
|
||||
)
|
||||
.filter(org_policies::atype.eq(policy_type as i32))
|
||||
.filter(org_policies::enabled.eq(true))
|
||||
@@ -254,21 +253,21 @@ impl OrgPolicy {
|
||||
/// and the user is not an owner or admin of that org. This is only useful for checking
|
||||
/// applicability of policy types that have these particular semantics.
|
||||
pub async fn is_applicable_to_user(
|
||||
user_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
policy_type: OrgPolicyType,
|
||||
exclude_org_uuid: Option<&str>,
|
||||
exclude_org_uuid: Option<&OrganizationId>,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
for policy in
|
||||
OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(user_uuid, policy_type, conn).await
|
||||
{
|
||||
// Check if we need to skip this organization.
|
||||
if exclude_org_uuid.is_some() && exclude_org_uuid.unwrap() == policy.org_uuid {
|
||||
if exclude_org_uuid.is_some() && *exclude_org_uuid.unwrap() == policy.org_uuid {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(user) = UserOrganization::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if user.atype < UserOrgType::Admin {
|
||||
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if user.atype < MembershipType::Admin {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -277,8 +276,8 @@ impl OrgPolicy {
|
||||
}
|
||||
|
||||
pub async fn is_user_allowed(
|
||||
user_uuid: &str,
|
||||
org_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
org_uuid: &OrganizationId,
|
||||
exclude_current_org: bool,
|
||||
conn: &mut DbConn,
|
||||
) -> OrgPolicyResult {
|
||||
@@ -306,7 +305,7 @@ impl OrgPolicy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn org_is_reset_password_auto_enroll(org_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &mut DbConn) -> bool {
|
||||
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
|
||||
Some(policy) => match serde_json::from_str::<ResetPasswordDataModel>(&policy.data) {
|
||||
Ok(opts) => {
|
||||
@@ -322,12 +321,12 @@ impl OrgPolicy {
|
||||
|
||||
/// Returns true if the user belongs to an org that has enabled the `DisableHideEmail`
|
||||
/// option of the `Send Options` policy, and the user is not an owner or admin of that org.
|
||||
pub async fn is_hide_email_disabled(user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||
pub async fn is_hide_email_disabled(user_uuid: &UserId, conn: &mut DbConn) -> bool {
|
||||
for policy in
|
||||
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
|
||||
{
|
||||
if let Some(user) = UserOrganization::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if user.atype < UserOrgType::Admin {
|
||||
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
|
||||
if user.atype < MembershipType::Admin {
|
||||
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
|
||||
Ok(opts) => {
|
||||
if opts.disable_hide_email {
|
||||
@@ -342,10 +341,19 @@ 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(
|
||||
member_uuid: &MembershipId,
|
||||
policy_type: OrgPolicyType,
|
||||
conn: &mut DbConn,
|
||||
) -> bool {
|
||||
if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await {
|
||||
if let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await {
|
||||
return policy.enabled;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct OrgPolicyId(String);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@ use serde_json::Value;
|
||||
|
||||
use crate::util::LowerCase;
|
||||
|
||||
use super::User;
|
||||
use super::{OrganizationId, User, UserId};
|
||||
use id::SendId;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -11,11 +12,10 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct Send {
|
||||
pub uuid: String,
|
||||
|
||||
pub user_uuid: Option<String>,
|
||||
pub organization_uuid: Option<String>,
|
||||
pub uuid: SendId,
|
||||
|
||||
pub user_uuid: Option<UserId>,
|
||||
pub organization_uuid: Option<OrganizationId>,
|
||||
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
@@ -51,7 +51,7 @@ impl Send {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: SendId::from(crate::util::get_uuid()),
|
||||
user_uuid: None,
|
||||
organization_uuid: None,
|
||||
|
||||
@@ -243,7 +243,7 @@ impl Send {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<String> {
|
||||
pub async fn update_users_revision(&self, conn: &mut DbConn) -> Vec<UserId> {
|
||||
let mut user_uuids = Vec::new();
|
||||
match &self.user_uuid {
|
||||
Some(user_uuid) => {
|
||||
@@ -257,7 +257,7 @@ impl Send {
|
||||
user_uuids
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
for send in Self::find_by_user(user_uuid, conn).await {
|
||||
send.delete(conn).await?;
|
||||
}
|
||||
@@ -268,20 +268,19 @@ 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) {
|
||||
Ok(u) => u.to_string(),
|
||||
Ok(u) => SendId::from(u.to_string()),
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
Self::find_by_uuid(&uuid, conn).await
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &SendId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
sends::table
|
||||
.filter(sends::uuid.eq(uuid))
|
||||
@@ -291,7 +290,18 @@ impl Send {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_uuid_and_user(uuid: &SendId, user_uuid: &UserId, 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: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
sends::table
|
||||
.filter(sends::user_uuid.eq(user_uuid))
|
||||
@@ -299,7 +309,7 @@ impl Send {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn size_by_user(user_uuid: &str, conn: &mut DbConn) -> Option<i64> {
|
||||
pub async fn size_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Option<i64> {
|
||||
let sends = Self::find_by_user(user_uuid, conn).await;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -322,7 +332,7 @@ impl Send {
|
||||
Some(total)
|
||||
}
|
||||
|
||||
pub async fn find_by_org(org_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! {conn: {
|
||||
sends::table
|
||||
.filter(sends::organization_uuid.eq(org_uuid))
|
||||
@@ -339,3 +349,48 @@ impl Send {
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// separate namespace to avoid name collision with std::marker::Send
|
||||
pub mod id {
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use macros::{IdFromParam, UuidFromParam};
|
||||
use std::marker::Send;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct SendId(String);
|
||||
|
||||
impl AsRef<Path> for SendId {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &Path {
|
||||
Path::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
|
||||
)]
|
||||
pub struct SendFileId(String);
|
||||
|
||||
impl AsRef<Path> for SendFileId {
|
||||
#[inline]
|
||||
fn as_ref(&self) -> &Path {
|
||||
Path::new(&self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use super::UserId;
|
||||
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
|
||||
|
||||
db_object! {
|
||||
@@ -7,8 +8,8 @@ db_object! {
|
||||
#[diesel(table_name = twofactor)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct TwoFactor {
|
||||
pub uuid: String,
|
||||
pub user_uuid: String,
|
||||
pub uuid: TwoFactorId,
|
||||
pub user_uuid: UserId,
|
||||
pub atype: i32,
|
||||
pub enabled: bool,
|
||||
pub data: String,
|
||||
@@ -41,9 +42,9 @@ pub enum TwoFactorType {
|
||||
|
||||
/// Local methods
|
||||
impl TwoFactor {
|
||||
pub fn new(user_uuid: String, atype: TwoFactorType, data: String) -> Self {
|
||||
pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self {
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: TwoFactorId(crate::util::get_uuid()),
|
||||
user_uuid,
|
||||
atype: atype as i32,
|
||||
enabled: true,
|
||||
@@ -118,7 +119,7 @@ impl TwoFactor {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
twofactor::table
|
||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||
@@ -129,7 +130,7 @@ impl TwoFactor {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_user_and_type(user_uuid: &str, atype: i32, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
twofactor::table
|
||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||
@@ -140,7 +141,7 @@ impl TwoFactor {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
@@ -217,3 +218,6 @@ impl TwoFactor {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TwoFactorId(String);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::{api::EmptyResult, auth::ClientIp, db::DbConn, error::MapResult, CONFIG};
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
auth::ClientIp,
|
||||
db::{
|
||||
models::{DeviceId, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::MapResult,
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = twofactor_incomplete)]
|
||||
#[diesel(primary_key(user_uuid, device_uuid))]
|
||||
pub struct TwoFactorIncomplete {
|
||||
pub user_uuid: String,
|
||||
pub user_uuid: UserId,
|
||||
// This device UUID is simply what's claimed by the device. It doesn't
|
||||
// necessarily correspond to any UUID in the devices table, since a device
|
||||
// must complete 2FA login before being added into the devices table.
|
||||
pub device_uuid: String,
|
||||
pub device_uuid: DeviceId,
|
||||
pub device_name: String,
|
||||
pub device_type: i32,
|
||||
pub login_time: NaiveDateTime,
|
||||
pub ip_address: String,
|
||||
}
|
||||
@@ -20,9 +30,10 @@ db_object! {
|
||||
|
||||
impl TwoFactorIncomplete {
|
||||
pub async fn mark_incomplete(
|
||||
user_uuid: &str,
|
||||
device_uuid: &str,
|
||||
user_uuid: &UserId,
|
||||
device_uuid: &DeviceId,
|
||||
device_name: &str,
|
||||
device_type: i32,
|
||||
ip: &ClientIp,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
@@ -44,6 +55,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()),
|
||||
))
|
||||
@@ -52,7 +64,7 @@ impl TwoFactorIncomplete {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn mark_complete(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &mut DbConn) -> EmptyResult {
|
||||
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -60,7 +72,11 @@ impl TwoFactorIncomplete {
|
||||
Self::delete_by_user_and_device(user_uuid, device_uuid, conn).await
|
||||
}
|
||||
|
||||
pub async fn find_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_user_and_device(
|
||||
user_uuid: &UserId,
|
||||
device_uuid: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
twofactor_incomplete::table
|
||||
.filter(twofactor_incomplete::user_uuid.eq(user_uuid))
|
||||
@@ -85,7 +101,11 @@ impl TwoFactorIncomplete {
|
||||
Self::delete_by_user_and_device(&self.user_uuid, &self.device_uuid, conn).await
|
||||
}
|
||||
|
||||
pub async fn delete_by_user_and_device(user_uuid: &str, device_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_by_user_and_device(
|
||||
user_uuid: &UserId,
|
||||
device_uuid: &DeviceId,
|
||||
conn: &mut DbConn,
|
||||
) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(twofactor_incomplete::table
|
||||
.filter(twofactor_incomplete::user_uuid.eq(user_uuid))
|
||||
@@ -95,7 +115,7 @@ impl TwoFactorIncomplete {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::CONFIG;
|
||||
use super::{
|
||||
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
||||
};
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
crypto,
|
||||
db::DbConn,
|
||||
error::MapResult,
|
||||
util::{format_date, get_uuid, retry},
|
||||
CONFIG,
|
||||
};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
@@ -10,7 +21,7 @@ db_object! {
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct User {
|
||||
pub uuid: String,
|
||||
pub uuid: UserId,
|
||||
pub enabled: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
@@ -90,7 +101,7 @@ impl User {
|
||||
let email = email.to_lowercase();
|
||||
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
uuid: UserId(get_uuid()),
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -107,7 +118,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 +155,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
|
||||
@@ -162,8 +173,8 @@ impl User {
|
||||
/// * `password` - A str which contains a hashed version of the users master password.
|
||||
/// * `new_key` - A String which contains the new aKey value of the users master password.
|
||||
/// * `allow_next_route` - A Option<Vec<String>> with the function names of the next allowed (rocket) routes.
|
||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
///
|
||||
pub fn set_password(
|
||||
&mut self,
|
||||
@@ -188,15 +199,15 @@ 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.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `route_exception` - A Vec<String> with the function names of the next allowed (rocket) routes.
|
||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
/// These routes are able to use the previous stamp id for the next 2 minutes.
|
||||
/// After these 2 minutes this stamp will expire.
|
||||
///
|
||||
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
||||
let stamp_exception = UserStampException {
|
||||
@@ -213,20 +224,11 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
use super::{
|
||||
Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, TwoFactorIncomplete, UserOrgType,
|
||||
UserOrganization,
|
||||
};
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
/// Database methods
|
||||
impl User {
|
||||
pub async fn to_json(&self, conn: &mut DbConn) -> Value {
|
||||
let mut orgs_json = Vec::new();
|
||||
for c in UserOrganization::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
orgs_json.push(c.to_json(conn).await);
|
||||
}
|
||||
|
||||
@@ -259,13 +261,14 @@ impl User {
|
||||
"forcePasswordReset": false,
|
||||
"avatarColor": self.avatar_color,
|
||||
"usesKeyConnector": false,
|
||||
"creationDate": format_date(&self.created_at),
|
||||
"object": "profile",
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
|
||||
if self.email.trim().is_empty() {
|
||||
err!("User email can't be empty")
|
||||
if !crate::util::is_valid_email(&self.email) {
|
||||
err!(format!("User email {} is not a valid email address", self.email))
|
||||
}
|
||||
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
@@ -302,19 +305,18 @@ impl User {
|
||||
}
|
||||
|
||||
pub async fn delete(self, conn: &mut DbConn) -> EmptyResult {
|
||||
for user_org in UserOrganization::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
if user_org.atype == UserOrgType::Owner
|
||||
&& UserOrganization::count_confirmed_by_org_and_type(&user_org.org_uuid, UserOrgType::Owner, conn).await
|
||||
<= 1
|
||||
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||
if member.atype == MembershipType::Owner
|
||||
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
||||
{
|
||||
err!("Can't delete last owner")
|
||||
}
|
||||
}
|
||||
|
||||
Send::delete_all_by_user(&self.uuid, conn).await?;
|
||||
super::Send::delete_all_by_user(&self.uuid, conn).await?;
|
||||
EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?;
|
||||
EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?;
|
||||
UserOrganization::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Membership::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Cipher::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Favorite::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Folder::delete_all_by_user(&self.uuid, conn).await?;
|
||||
@@ -330,7 +332,7 @@ impl User {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn update_uuid_revision(uuid: &str, conn: &mut DbConn) {
|
||||
pub async fn update_uuid_revision(uuid: &UserId, conn: &mut DbConn) {
|
||||
if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await {
|
||||
warn!("Failed to update revision for {}: {:#?}", uuid, e);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -355,9 +357,9 @@ impl User {
|
||||
Self::_update_revision(&self.uuid, &self.updated_at, conn).await
|
||||
}
|
||||
|
||||
async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &mut DbConn) -> EmptyResult {
|
||||
async fn _update_revision(uuid: &UserId, 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)
|
||||
@@ -377,7 +379,7 @@ impl User {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
pub async fn find_by_uuid(uuid: &UserId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! {conn: {
|
||||
users::table.filter(users::uuid.eq(uuid)).first::<UserDb>(conn).ok().from_db()
|
||||
}}
|
||||
@@ -406,8 +408,8 @@ impl Invitation {
|
||||
}
|
||||
|
||||
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
if self.email.trim().is_empty() {
|
||||
err!("Invitation email can't be empty")
|
||||
if !crate::util::is_valid_email(&self.email) {
|
||||
err!(format!("Invitation email {} is not a valid email address", self.email))
|
||||
}
|
||||
|
||||
db_run! {conn:
|
||||
@@ -456,3 +458,23 @@ impl Invitation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
DieselNewType,
|
||||
FromForm,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
AsRef,
|
||||
Deref,
|
||||
Display,
|
||||
From,
|
||||
UuidFromParam,
|
||||
)]
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct UserId(String);
|
||||
|
||||
@@ -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,
|
||||
@@ -216,6 +226,7 @@ table! {
|
||||
collection_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +296,7 @@ table! {
|
||||
groups_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -216,6 +226,7 @@ table! {
|
||||
collection_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +296,7 @@ table! {
|
||||
groups_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -216,6 +226,7 @@ table! {
|
||||
collection_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +296,7 @@ table! {
|
||||
groups_uuid -> Text,
|
||||
read_only -> Bool,
|
||||
hide_passwords -> Bool,
|
||||
manage -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{name_server::TokioConnectionProvider, TokioResolver};
|
||||
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<TokioResolver>),
|
||||
}
|
||||
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 TokioResolver::builder(TokioConnectionProvider::default()) {
|
||||
Ok(builder) => {
|
||||
let resolver = builder.build();
|
||||
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()))
|
||||
})
|
||||
}
|
||||
}
|
||||
166
src/mail.rs
166
src/mail.rs
@@ -1,7 +1,6 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
|
||||
use std::{env::consts::EXE_SUFFIX, str::FromStr};
|
||||
|
||||
use lettre::{
|
||||
message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart},
|
||||
@@ -17,6 +16,7 @@ use crate::{
|
||||
encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims,
|
||||
generate_verify_email_claims,
|
||||
},
|
||||
db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
@@ -25,7 +25,7 @@ fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> {
|
||||
if let Some(command) = CONFIG.sendmail_command() {
|
||||
AsyncSendmailTransport::new_with_command(command)
|
||||
} else {
|
||||
AsyncSendmailTransport::new()
|
||||
AsyncSendmailTransport::new_with_command(format!("sendmail{EXE_SUFFIX}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,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 +139,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))
|
||||
}
|
||||
|
||||
@@ -137,8 +165,8 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
|
||||
let claims = generate_delete_claims(uuid.to_string());
|
||||
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_delete_claims(user_id.to_string());
|
||||
let delete_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -146,7 +174,7 @@ pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"user_id": uuid,
|
||||
"user_id": user_id,
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"token": delete_token,
|
||||
}),
|
||||
@@ -155,8 +183,8 @@ pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(uuid.to_string());
|
||||
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(user_id.clone());
|
||||
let verify_email_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -164,7 +192,7 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"user_id": uuid,
|
||||
"user_id": user_id,
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"token": verify_email_token,
|
||||
}),
|
||||
@@ -173,6 +201,27 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
|
||||
let query_string = match query.query() {
|
||||
None => err!("Failed to build verify URL query parameters"),
|
||||
Some(query) => query,
|
||||
};
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/register_verify_email",
|
||||
json!({
|
||||
// `url.Url` would place the anchor `#` after the query parameters
|
||||
"url": format!("{}/#/finish-signup/?{}", CONFIG.domain(), query_string),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"email": email,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(email, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_welcome(address: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/welcome",
|
||||
@@ -185,8 +234,8 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(uuid.to_string());
|
||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
||||
let claims = generate_verify_email_claims(user_id.clone());
|
||||
let verify_email_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
@@ -194,7 +243,7 @@ pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"user_id": uuid,
|
||||
"user_id": user_id,
|
||||
"token": verify_email_token,
|
||||
}),
|
||||
)?;
|
||||
@@ -229,65 +278,88 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) ->
|
||||
}
|
||||
|
||||
pub async fn send_invite(
|
||||
address: &str,
|
||||
uuid: &str,
|
||||
org_id: Option<String>,
|
||||
org_user_id: Option<String>,
|
||||
user: &User,
|
||||
org_id: OrganizationId,
|
||||
member_id: MembershipId,
|
||||
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(),
|
||||
member_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)
|
||||
.append_pair("organizationUserId", &member_id)
|
||||
.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(
|
||||
address: &str,
|
||||
uuid: &str,
|
||||
emer_id: &str,
|
||||
user_id: UserId,
|
||||
emer_id: EmergencyAccessId,
|
||||
grantor_name: &str,
|
||||
grantor_email: &str,
|
||||
) -> EmptyResult {
|
||||
let claims = generate_emergency_access_invite_claims(
|
||||
String::from(uuid),
|
||||
user_id,
|
||||
String::from(address),
|
||||
String::from(emer_id),
|
||||
emer_id.clone(),
|
||||
String::from(grantor_name),
|
||||
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.to_string())
|
||||
.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 +499,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 +509,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 +518,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 +534,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(),
|
||||
}),
|
||||
@@ -537,13 +615,13 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
|
||||
// Match some common errors and make them more user friendly
|
||||
Err(e) => {
|
||||
if e.is_client() {
|
||||
debug!("Sendmail client error: {:#?}", e);
|
||||
debug!("Sendmail client error: {:?}", e);
|
||||
err!(format!("Sendmail client error: {e}"));
|
||||
} else if e.is_response() {
|
||||
debug!("Sendmail response error: {:#?}", e);
|
||||
debug!("Sendmail response error: {:?}", e);
|
||||
err!(format!("Sendmail response error: {e}"));
|
||||
} else {
|
||||
debug!("Sendmail error: {:#?}", e);
|
||||
debug!("Sendmail error: {:?}", e);
|
||||
err!(format!("Sendmail error: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
235
src/main.rs
235
src/main.rs
@@ -24,8 +24,11 @@ extern crate log;
|
||||
extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel_migrations;
|
||||
#[macro_use]
|
||||
extern crate diesel_derive_newtype;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{canonicalize, create_dir_all},
|
||||
panic,
|
||||
path::Path,
|
||||
@@ -39,6 +42,9 @@ use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::SignalKind;
|
||||
|
||||
#[macro_use]
|
||||
mod error;
|
||||
mod api;
|
||||
@@ -47,36 +53,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 +88,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 +102,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 +117,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 +173,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 +184,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 +245,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 +307,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 +383,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 +438,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 +513,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 +595,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 +682,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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user