Compare commits

..

266 Commits

Author SHA1 Message Date
Daniel García
72e1946ce5 Merge pull request #1799 from BlackDex/issue-1796
Fixes issue with multiple security keys.
2021-06-27 18:23:15 +02:00
BlackDex
ee391720aa Fixes issue with multiple security keys.
- Updated webauthn-rs commit hash to resolve #1796
2021-06-27 18:12:27 +02:00
Daniel García
e3a2dfffab Formatting 2021-06-26 14:21:58 +02:00
Daniel García
8bf1278b1b Update web vault and docker base images 2021-06-26 14:08:06 +02:00
Daniel García
00ce943ea5 Merge branch 'BlackDex-security-md' into main 2021-06-26 13:36:14 +02:00
Daniel García
b67eacdfde Merge branch 'security-md' of https://github.com/BlackDex/vaultwarden into BlackDex-security-md 2021-06-26 13:36:05 +02:00
Daniel García
0dcea75764 Remove unused lifetime and double referencing 2021-06-26 13:35:09 +02:00
BlackDex
0c5532d8b5 Adding a SECURITY.md 2021-06-26 11:49:00 +02:00
Daniel García
46e0f3c43a Load RSA keys as pem format directly, and using openssl crate, backported from async branch 2021-06-25 20:53:26 +02:00
Daniel García
2cd17fe7af Add token with short expiration time to send url 2021-06-25 20:53:26 +02:00
Daniel García
f44b2611e6 Update rust toolchain and dependencies 2021-06-25 20:53:26 +02:00
Mathijs van Veluw
82fee0ede3 Merge pull request #1779 from jjlin/last-known-rev-warning
Avoid `Error parsing LastKnownRevisionDate` warning for mobile clients
2021-06-20 18:07:18 +02:00
Jeremy Lin
49579e4ce7 Avoid Error parsing LastKnownRevisionDate warning for mobile clients
When creating a new cipher, the mobile clients seem to set this field to an
invalid value, which causes a warning to be logged:

    Error parsing LastKnownRevisionDate '0001-01-01T00:00:00': premature end of input

Avoid this by dropping the `LastKnownRevisionDate` field on cipher creation.
2021-06-19 21:32:11 -07:00
Daniel García
9254cf9d9c Fix clippy lints 2021-06-19 22:02:03 +02:00
Daniel García
ff0fee3690 Merge branch 'BlackDex-admin-changes' into main 2021-06-19 21:38:58 +02:00
Daniel García
0778bd4bd5 Merge branch 'admin-changes' of https://github.com/BlackDex/vaultwarden into BlackDex-admin-changes 2021-06-19 21:27:25 +02:00
Daniel García
0cd065d354 Update webauthn-rs crate to upstream version 2021-06-19 21:25:55 +02:00
BlackDex
8615736e84 Multiple Admin Interface fixes and some others.
Misc:
- Fixed hadolint workflow, new git cli needs some extra arguments.
- Add ignore paths to all specific on triggers.
- Updated hadolint version.
- Made SMTP_DEBUG read-only, since it can't be changed at runtime.

Admin:
- Migrated from Bootstrap v4 to v5
- Updated jquery to v3.6.0
- Updated Datatables
- Made Javascript strict
- Added a way to show which ENV Vars are overridden.
- Changed the way to provide data for handlebars.
- Fixed date/time check.
- Made support string use details and summary feature of markdown/github.
2021-06-19 19:22:19 +02:00
Daniel García
5772836be5 Fix admin page with handlebars 4 2021-06-16 22:57:28 +02:00
Daniel García
c380d9c379 Support for webauthn and u2f->webauthn migrations 2021-06-16 19:06:40 +02:00
Daniel García
cea7a30d82 Merge pull request #1761 from jjlin/deps
Update dependencies
2021-06-10 21:03:05 +02:00
Jeremy Lin
06cde29419 Update dependencies
Notably, update `diesel` to 1.4.7 and `libsqlite3-sys` to 0.22.2 to pick up
the fix for CVE-2021-20227 added in SQLite 3.34.1.
2021-06-09 01:44:29 -07:00
Daniel García
20f5988174 Merge pull request #1736 from jjlin/rocket-env-docs
Clarify Rocket env var defaults
2021-06-04 20:03:17 +02:00
Jeremy Lin
b491cfe0b0 Clarify Rocket env var defaults
Mention `ROCKET_WORKERS`, but remove `ROCKET_ENV` since most users
probably wouldn't use it.
2021-05-31 13:13:02 -07:00
Daniel García
fc513413ea Merge pull request #1730 from jjlin/attachment-upload-v2
Add support for v2 attachment upload APIs
2021-05-30 22:27:52 +02:00
Jeremy Lin
3f7e4712cd Fix attachment size limit calculation for v2 uploads 2021-05-25 23:17:22 -07:00
Jeremy Lin
c2ef331df9 Rework file ID generation 2021-05-25 23:15:24 -07:00
Jeremy Lin
5fef7983f4 Clean up attachment error handling 2021-05-25 22:13:04 -07:00
Jeremy Lin
29ed82a359 Add support for v2 attachment upload APIs
Upstream PR: https://github.com/bitwarden/server/pull/1229
2021-05-25 04:14:51 -07:00
Daniel García
7d5186e40a Merge pull request #1706 from jjlin/trash-auto-delete-env
Add `TRASH_AUTO_DELETE_DAYS` to .env.template
2021-05-17 17:21:34 +02:00
Daniel García
99270612ba Merge pull request #1704 from jjlin/global-domains
Sync global_domains.json
2021-05-17 17:21:09 +02:00
Jeremy Lin
c7b5b6ee07 Add TRASH_AUTO_DELETE_DAYS to .env.template 2021-05-16 17:51:54 -07:00
Jeremy Lin
848d17ffb9 Sync global_domains.json to bitwarden/server@7857053 (Amazon) 2021-05-16 15:16:41 -07:00
Daniel García
47e8aa29e1 Merge pull request #1702 from BlackDex/icon-updates-plus
Updated icon fetching and crates.
2021-05-16 23:35:37 +02:00
BlackDex
f270f2ed65 Updated icon fetching and crates.
- Updated some crates
- Updated icon fetching code:
  + Use a cookie jar and set Max-Age to 2 minutes for all cookies
  + Locate the base href tag to fix some locations
  + Changed User-Agent (Helps on some sites to get HTML instead of JS)
  + Reduced HTML code limit from 512KB to 384KB
  + Allow some large icons higer-up in the sort
  + Allow GIF images
  + Ignore cookie_store and hyper::client debug messages
2021-05-16 15:29:13 +02:00
Daniel García
aba5b234af Merge pull request #1700 from jjlin/fix-attachment-downloads
Fix attachment downloads
2021-05-16 14:11:21 +02:00
Jeremy Lin
9133e2927d Fix attachment downloads
Upstream switched to new upload/download APIs. Uploads fall back to the
legacy APIs for now, but not downloads apparently.
2021-05-15 22:46:57 -07:00
Jeremy Lin
38104ba7cf cargo fmt changes
The PR build seems to fail without this...
2021-05-15 22:46:37 -07:00
Daniel García
c42bcae224 Merge pull request #1696 from umireon/patch-1
Remove unneeded spaces in .env.template
2021-05-14 17:40:05 +02:00
Kaito Udagawa
764e51bbe9 Remove unneeded spaces in .env.template 2021-05-14 22:36:42 +09:00
Daniel García
8e6c6a1dc4 Merge pull request #1689 from jjlin/hide-email
Add support for hiding the sender's email address in Bitwarden Sends
2021-05-12 23:05:53 +02:00
Daniel García
7a9cfc45da Merge pull request #1688 from jjlin/config-sends-allowed
Add `sends_allowed` config setting
2021-05-12 23:05:41 +02:00
Daniel García
9e24b9065c Merge pull request #1682 from dongcarl/2021-05-admin-granular-http-codes
admin: More granular HTTP return codes for user-related endpoints
2021-05-12 23:05:30 +02:00
Daniel García
1c2b376ca2 Merge pull request #1663 from dongcarl/2021-05-invite_user-return
admin: Return newly-created user in invite_user
2021-05-12 23:05:20 +02:00
Daniel García
746ce2afb4 Merge pull request #1653 from jjlin/password-reprompt
Add support for password reprompt
2021-05-12 23:05:01 +02:00
Jeremy Lin
029008bad5 Add support for the Send Options policy
Upstream refs:

* https://github.com/bitwarden/server/pull/1234
* https://bitwarden.com/help/article/policies/#send-options
2021-05-12 01:22:12 -07:00
Jeremy Lin
d3449bfa00 Add support for hiding the sender's email address in Bitwarden Sends
Note: The original Vaultwarden implementation of Bitwarden Send would always
hide the email address, while the upstream implementation would always show it.

Upstream PR: https://github.com/bitwarden/server/pull/1234
2021-05-11 22:51:12 -07:00
Jeremy Lin
a9a5706764 Add support for password reprompt
Upstream PR: https://github.com/bitwarden/server/pull/1269
2021-05-11 20:09:57 -07:00
Jeremy Lin
3ff8014add Add sends_allowed config setting
This provides global control over whether users can create Bitwarden Sends.
2021-05-11 20:07:32 -07:00
Carl Dong
e60bdc7efe admin: Make invite_user error codes more specific
- Return 409 Conflict for when a user with that email already exists
- Return 500 InternalServerError for everything else
2021-05-10 11:47:41 -04:00
Carl Dong
cccd8262fa admin: Add /users/<uuid> route
Individual user information can now be looked up by UUID.
2021-05-10 11:47:41 -04:00
Carl Dong
68e5d95d25 admin: Specifically return 404 for user not found
- Modify err_code to accept an expr for err_code
- Add get_user_or_404, properly returning 404 instead of a generic 400
  for cases where user is not found
- Use get_user_or_404 where appropriate.
2021-05-10 11:47:41 -04:00
Carl Dong
5f458b288a admin: Return newly-created user in invite_user
Instead of having the caller dig through /admin/users for the right one,
just return the user upon creation.
2021-05-10 11:47:41 -04:00
Daniel García
e9ee8ac2fa Fix sponsors 2021-05-08 19:01:51 +02:00
Daniel García
8a4dfc3bbe Merge pull request #1670 from BlackDex/branding-and-email
Updated branding, email and crates
2021-05-08 18:40:57 +02:00
Daniel García
4f86517501 Merge pull request #1679 from BlackDex/gh-workflows
Updated Pipelines and fixed new Hadolints
2021-05-08 18:40:41 +02:00
BlackDex
7cb19ef767 Updated branding, email and crates
- Updated branding for admin and emails
- Updated crates and some deprications
- Removed newline-converter because this is built-in into lettre
- Updated email templates to use a shared header and footer template
- Also trigger SMTP SSL When TLS is selected without SSL
  Resolves #1641
2021-05-08 17:46:31 +02:00
BlackDex
565439a914 Updated Pipelines and fixed new Hadolints
- Removed azure-pipelines
- Updated gh-actions to run `cargo test` per db feature
- Fail on warnings by adding `RUSTFLAGS` env
- Updated Dockerfile to fix some new hadolint warnings
2021-05-08 16:48:48 +02:00
Daniel García
b8010be26b Extract some FromDb trait impls outside the macros so they aren't repeated, and fix some clippy lints 2021-05-02 17:49:25 +02:00
Daniel García
f76b8a32ca Update dependencies 2021-05-02 17:48:06 +02:00
Daniel García
0d63132987 Rename the title too, not just the URL 2021-04-30 22:43:40 +02:00
Daniel García
7b5d5d1302 Rename references to the discourse forum 2021-04-30 22:40:12 +02:00
Daniel García
0dc98bda23 Merge pull request #1650 from mprasil/main
Point docker hub badge to correct repository
2021-04-30 21:29:32 +02:00
Miro Prasil
f9a062cac8 Point docker hub badge to correct repository
The link is already pointing to the new image location, but the badge
source was somewhat confusingly showing stats for the old image.
2021-04-30 19:29:17 +01:00
Daniel García
6ad4ccd901 Merge pull request #1640 from Proxymiity/patch-1
Fixed a typo in the readme
2021-04-30 16:22:39 +02:00
Daniel García
ee6ceaa923 Update README.md 2021-04-30 16:20:52 +02:00
Daniel García
20b393d354 Update README.md 2021-04-30 16:19:26 +02:00
Proxymiity ☆
daea54b288 Renamed bw-data according to project name change 2021-04-29 21:13:41 +02:00
Daniel García
1e5306b820 Remove warning when compiling only with mysql and add compatibility mode with the old docker script names 2021-04-29 16:01:04 +02:00
Daniel García
6890c25ea1 Merge pull request #1636 from rkowalewski/fix-libressl-332
Update openssl crate to support LibreSSL 3.3.2
2021-04-29 16:00:14 +02:00
rkowalewski
48482fece0 Merge branch 'main' into fix-libressl-332 2021-04-29 08:34:10 +02:00
Roger Kowalewski
1dc1d4df72 update openssl crate to support LibreSSL 3.3.2 2021-04-29 10:04:08 +02:00
Daniel García
2b4dd6f137 Fix branch name 2021-04-28 21:46:20 +02:00
Daniel García
3da44a8d30 Fix formatting 2021-04-27 23:39:36 +02:00
Daniel García
34ea10475d Project renaming 2021-04-27 23:18:32 +02:00
Daniel García
ced7f1771a Update dependencies 2021-04-15 18:38:00 +02:00
Daniel García
af2235bf88 Merge branch 'RealOrangeOne-fmt' 2021-04-15 18:30:50 +02:00
Daniel García
305de2e2cd Format the changes from merge to master 2021-04-15 18:30:23 +02:00
Daniel García
8756c5c255 Merge branch 'fmt' of https://github.com/RealOrangeOne/bitwarden_rs into RealOrangeOne-fmt 2021-04-15 18:29:03 +02:00
Daniel García
27609ac4cc Update README.md 2021-04-15 18:27:05 +02:00
Daniel García
95d906bdbb Merge branch 'master' into fmt 2021-04-15 18:24:04 +02:00
Daniel García
4bb0d7bc05 Merge pull request #1587 from RealOrangeOne/request-proxy
Allow outbound requests to go via a proxy
2021-04-15 17:40:39 +02:00
Daniel García
d9599155ae Merge pull request #1602 from jjlin/backup-warning
Warn that the SQLite backup feature doesn't produce a complete backup
2021-04-15 17:38:31 +02:00
Jeremy Lin
244bad3a24 Warn that the SQLite backup feature doesn't produce a complete backup
Also add a link to the wiki page on backups.
2021-04-09 22:30:39 -07:00
Jake Howard
f7056bcaa5 Enable socks feature for reqwest
This allowed HTTP_PROXY be set with a socks5 proxy
2021-04-07 19:25:02 +01:00
Jake Howard
994669fb69 Merge remote-tracking branch 'origin/master' into fmt 2021-04-06 21:55:28 +01:00
Jake Howard
3ab90259f2 Modify rustfmt file 2021-04-06 21:54:42 +01:00
Jake Howard
155109dea1 Extract client creation to a single place 2021-04-06 21:04:37 +01:00
Daniel García
b268c3dd1c Update web vault and add unnoficialserver response 2021-04-06 20:38:22 +02:00
Daniel García
4e64dbdde4 Merge pull request #1579 from jjlin/job-scheduler
Add support for auto-deleting trashed items
2021-04-06 19:48:49 +02:00
Daniel García
a2955daffe Merge pull request #1576 from jjlin/global-domains
Sync global_domains.json
2021-04-06 19:36:11 +02:00
Daniel García
d3921b973b Merge pull request #1583 from BlackDex/icon-updates
Updated icon fetching.
2021-04-06 19:35:51 +02:00
Daniel García
cf6ad3cb15 Merge pull request #1584 from BlackDex/admin-interface
Some admin interface updates.
2021-04-06 19:33:15 +02:00
Jeremy Lin
90e0b7fec6 Offset scheduled jobs by 5 minutes
This is intended to avoid contention with database backups that many users
probably schedule to start at exactly the top of an hour.
2021-04-05 23:20:08 -07:00
Jeremy Lin
d77333576b Add support for auto-deleting trashed items
Upstream will soon auto-delete trashed items after 30 days, but some people
use the trash as an archive folder, so to avoid unexpected data loss, this
implementation requires the user to explicitly enable auto-deletion.
2021-04-05 23:07:25 -07:00
Jeremy Lin
73ff8d79f7 Add a generic job scheduler
Also rewrite deletion of old sends using the job scheduler.
2021-04-05 23:07:15 -07:00
BlackDex
95fc88ae5b Some admin interface updates.
- Fixed bug when web-vault is disabled.
- Updated sql-server version check to be simpler thx to @weiznich ( https://github.com/dani-garcia/bitwarden_rs/pull/1548#discussion_r604767196 )
- Use `VACUUM INTO` to create a SQLite backup instead of using the external sqlite3 application.
  - This also removes the dependancy of having the sqlite3 packages installed on the final image unnecessary, and thus removed it.
- Updated backup filename to also have the current time.
- Add specific bitwarden_rs web-vault version check (to match letter patched versions)
  Will work when https://github.com/dani-garcia/bw_web_builds/pull/33 is build (But still works without it also).
2021-04-05 15:09:16 +02:00
BlackDex
1d0eaac260 Updated icon fetching.
- Added image type checking, and prevent downloading non images.
  We didn't checked this before, which could in turn could allow someone
to download an arbitrary file.
- This also prevents SVG images from being used, while they work on the
  web-vault and desktop client, they didn't on the mobile versions.
- Because of this image type checking we can return a valid file type
  instead of only 'x-icon' (which is still used as a fallback).
- Prevent rel values with `icon-mask`, these are not valid favicons.
2021-04-03 22:51:44 +02:00
Jeremy Lin
3565bfc939 Sync global_domains.json to bitwarden/server@261916d (Stack Exchange) 2021-04-01 21:59:06 -07:00
Mathijs van Veluw
a82c04910f Merge pull request #1575 from RealOrangeOne/linguist-vendored
Just ignore scripts
2021-04-01 21:57:23 +02:00
Jake Howard
233f03ca2b Just ignore scripts
Nothing else in `src/static` is vendored external scripts, so just ignore these.

This also fixes the glob, which previously wasn't matching anything
2021-04-01 20:44:58 +01:00
Jake Howard
93c881a7a9 Reflow some lines manually 2021-03-31 21:45:05 +01:00
Jake Howard
0af3956abd Run cargo fmt on codebase 2021-03-31 21:18:35 +01:00
Jake Howard
15feff3e79 Add fmt to CI 2021-03-31 21:16:57 +01:00
Daniel García
5c5700caa7 Merge pull request #1565 from BlackDex/misc-updates
Misc changes.
2021-03-30 23:31:58 +02:00
Daniel García
3bddc176d6 Updated sponsors 2021-03-30 23:27:55 +02:00
BlackDex
9caf4bf383 Misc changes.
Some small changes in general:
- Moved the SQL Version check struct into the function.
- Updated hadolint to 2.0.0
- Fixed hadolint 2.0.0 warnings
- Updated github workflows
- Added .editorconfig for some general shared editor settings.
2021-03-30 21:45:10 +02:00
Daniel García
9b2234fa0e Merge pull request #1556 from mkilchhofer/docs/update_template
fix(env.template): IP_HEADER defaults to X-Real-IP
2021-03-29 23:35:50 +02:00
Daniel García
1f79fdec4e Merge pull request #1552 from BlackDex/misc-fixes
Icon and SMTP Debug fixes.
2021-03-29 23:35:31 +02:00
Marco Kilchhofer
a56f4c97e4 fix(env.template): IP_HEADER defaults to X-Real-IP
This was wrong in commit 88c56de97b.
2021-03-29 11:16:20 +02:00
BlackDex
3a3390963c Icon and SMTP Debug fixes.
- We need to add some feature to enable smtp debugging again. See: https://github.com/lettre/lettre/pull/584
- Upstream added the fallback icon again, probably because of caching ;). See: https://github.com/bitwarden/server/pull/1149
- Enabled gzip and brotli compression support with reqwest. Some sites seem to force this, or assume that because of the User-Agent string it is supported. This caused some failed icons.

Fixes #1540
2021-03-29 10:27:58 +02:00
Daniel García
fd27759a95 Merge pull request #1546 from RealOrangeOne/clippy-run
Run Clippy
2021-03-28 16:04:09 +02:00
Daniel García
01d8056c73 Merge pull request #1545 from RealOrangeOne/icon-client-cache
Client caching
2021-03-28 16:03:16 +02:00
Jake Howard
81fa33ebb5 Remove unnecessary reference 2021-03-28 10:59:49 +01:00
Jake Howard
e8aa3bc066 Merge branch 'master' into clippy-run 2021-03-28 10:51:25 +01:00
Jake Howard
0bf0125e82 Reverse negation on ordering
Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2021-03-28 10:49:29 +01:00
Jake Howard
6209e778e5 Icons should always be cached using full TTL 2021-03-28 10:39:12 +01:00
Daniel García
5323283f98 Merge pull request #1548 from BlackDex/admin-interface
Updated diagnostics page
2021-03-28 01:31:38 +01:00
BlackDex
57e17d0648 Updated diagnostics page
- Added reverse proxy check
- Better deffinition of internet proxy
- Added SQL Server version detection
2021-03-28 00:10:01 +01:00
Jake Howard
da55d5ec70 Also run actions CI on pull request
`push` only counts for pushes to branches on the repo, not forks
2021-03-27 15:21:00 +00:00
Jake Howard
828a060698 Run clippy on CI 2021-03-27 15:10:00 +00:00
Jake Howard
3e5971b9db Remove unnecessary result return types 2021-03-27 15:07:26 +00:00
Jake Howard
47c2625d38 Prevent clippy complaining at method
It's not incorrectly wrapped. We care about the return type being `Option`.
2021-03-27 14:36:50 +00:00
Jake Howard
49af9cf4f5 Correctly camelCase acronyms
https://rust-lang.github.io/rust-clippy/master/index.html#upper_case_acronyms
2021-03-27 14:26:32 +00:00
Jake Howard
6b1daeba05 Implement From over Into
https://rust-lang.github.io/rust-clippy/master/index.html#from_over_into
2021-03-27 14:19:57 +00:00
Jake Howard
9f1240d8d9 Only construct JSON object if it's useful 2021-03-27 14:03:46 +00:00
Jake Howard
a8138be69b Use if let more 2021-03-27 14:03:31 +00:00
Jake Howard
ea57dc3bc9 Use matches macro 2021-03-27 14:03:07 +00:00
Jake Howard
131348a49f Add immutable caching for vault assets
The URLs are cachebusted, so updates will still be applied cleanly and immediately
2021-03-27 13:37:56 +00:00
Jake Howard
b22564cb00 Cache icons on the client
This should make the vault pages load much faster, and massively reduce the number of requests.
2021-03-27 13:30:40 +00:00
Daniel García
16eb0a56f9 Exclude vendored scripts from Github language statistics 2021-03-25 21:39:34 +01:00
Daniel García
3e4ff47a38 Update dependencies, particularly openssl to 1.1.1k 2021-03-25 20:05:20 +01:00
Daniel García
8ea01a67f6 Merge pull request #1529 from mprasil/more-generic-send-error-messages
Return generic message when Send not available
2021-03-25 19:56:24 +01:00
Miro Prasil
aa5cc642e1 Use constant for the "inaccessible" error message 2021-03-25 11:40:32 +00:00
Daniel García
a121cb6f00 Merge pull request #1530 from jjlin/global-domains
Sync global_domains.json
2021-03-23 23:48:20 +01:00
Daniel García
60164182ae Fix alpine armv7 build
Reference: https://github.com/messense/rust-musl-cross/pull/34
2021-03-23 23:47:12 +01:00
Jeremy Lin
f842a80cdb Sync global_domains.json to bitwarden/server@455e4b2 (ProtonMail/ProtonVPN) 2021-03-23 11:30:00 -07:00
Miro Prasil
4b6a574ee0 Return generic message when Send not available
This should help avoid leaking information about (non)existence of Send
and be more in line with what official server returns.
2021-03-23 13:39:09 +00:00
Daniel García
f9ebb780f9 Update dependencies 2021-03-22 20:00:57 +01:00
Daniel García
1fc6c30652 Send deletion thread and updated users revision 2021-03-22 19:57:35 +01:00
Daniel García
46a1a013cd Update user revision date with sends 2021-03-22 19:05:15 +01:00
Daniel García
551810c486 Fix updating file send 2021-03-17 19:39:48 +01:00
Daniel García
b987ba506d Merge pull request #1493 from jjlin/send
Add support for the Disable Send policy
2021-03-16 18:13:55 +01:00
Daniel García
84810f2bb2 Remove unnecessary fields from send access 2021-03-16 18:11:25 +01:00
Jeremy Lin
424d666a50 Add support for the Disable Send policy
Upstream refs:

* https://github.com/bitwarden/server/pull/1130
* https://bitwarden.com/help/article/policies/#disable-send
2021-03-16 02:07:45 -07:00
Daniel García
a71359f647 Merge pull request #1469 from jjlin/cors
CORS fixes
2021-03-15 16:57:00 +01:00
Daniel García
d93c344176 Merge branch 'master' into cors 2021-03-15 16:49:12 +01:00
Daniel García
b9c3213b90 Merge pull request #1487 from jjlin/send
Send access check fixes
2021-03-15 16:47:14 +01:00
Daniel García
95e24ffc51 rename send key -> akey 2021-03-15 16:42:20 +01:00
Jeremy Lin
00d56d7295 Send access check fixes
Adjust checks for max access count, expiration date, and deletion date.
The date checks aren't that important, but the access count check
currently allows one more access than it should.
2021-03-14 23:20:49 -07:00
Daniel García
7436b454db Update web vault to 2.19.0 2021-03-14 23:36:49 +01:00
Daniel García
8da5b99482 Send API 2021-03-14 23:35:55 +01:00
Daniel García
2969e87b52 Add separate host-only fromrequest handler 2021-03-14 23:24:47 +01:00
Daniel García
ce62e898c3 Remove debug impl from database structs
This is only implemented for the database specific structs, which is not what we want
2021-03-13 22:04:04 +01:00
Daniel García
431462d839 Update dependencies and enable serde integration for chrono 2021-03-13 22:02:11 +01:00
Jeremy Lin
7d0e234b34 CORS fixes
* The Safari extension apparently now uses the origin `file://` and expects
  that to be returned (see bitwarden/browser#1311, bitwarden/server#800).

* The `Access-Control-Allow-Origin` header was reflecting the value of the
  `Origin` header without checking whether the origin was actually allowed.
  This effectively allows any origin to interact with the server, which
  defeats the purpose of CORS.
2021-03-07 00:35:08 -08:00
Daniel García
dad1b1bee9 Updated dependencies 2021-03-06 22:04:01 +01:00
Daniel García
9312cebee3 Merge pull request #1463 from std2main/patch-2
Add a dot in find command.
2021-03-06 00:04:12 +01:00
std2main
cdf5b6ec2d Add a dot in find command.
Add a dot indicting current directory to search by find.

find in mac won't work without the dot
2021-03-05 15:49:45 -05:00
Mathijs van Veluw
ce99fc8f95 Merge pull request #1460 from jjlin/invitation-org-name
Fix custom org name in invitation confirmation email
2021-03-04 08:15:34 +01:00
Jeremy Lin
a75d050001 Fix custom org name in invitation confirmation email
The org name in the invitation email was made customizable in 8867626, but
the org name is still hardcoded as "bitwarden_rs" in the confirmation email.
2021-03-03 23:03:55 -08:00
Daniel García
75cfd10f11 Merge pull request #1444 from jjlin/remove-md5
Remove `md5.js` dependency
2021-02-28 18:23:27 +01:00
Daniel García
9859ba6339 Merge pull request #1443 from jjlin/data-folder
Check for data folder on startup
2021-02-28 18:22:46 +01:00
Jeremy Lin
513056f711 Check for data folder on startup
Currently, when starting up for the first time (running standalone, outside
of Docker), bitwarden_rs panics when the `openssl` tool isn't able to create
`data/rsa_key.pem` due to the `data` dir not existing. Instead, print a more
helpful error message telling the user to create the directory.
2021-02-28 01:45:05 -08:00
Mathijs van Veluw
ebe334fcc7 Merge pull request #1447 from jjlin/issue-templates
Allow only bug report issues
2021-02-28 08:32:04 +01:00
Jeremy Lin
0eec12472e Allow only bug report issues
Remove templates for other issue types, directing them to the forum instead.
2021-02-27 22:13:51 -08:00
Jeremy Lin
39106d440a Remove md5.js dependency
Switch to the built-in WebCrypto APIs for computing identicon hashes.
2021-02-26 21:48:01 -08:00
Daniel García
9117095764 Update dependencies and web vault 2021-02-24 20:30:19 +01:00
Daniel García
099bba950c Merge pull request #1432 from jjlin/2fa
Change `twofactorauth.org` to `2fa.directory`
2021-02-24 20:05:57 +01:00
Jeremy Lin
e37ff60617 Change twofactorauth.org to 2fa.directory
The `twofactorauth.org` has apparently been sold to some company for
marketing purposes.
2021-02-23 18:51:07 -08:00
Daniel García
5b14608041 Update web vault to have better error messages when not using HTTPS 2021-02-20 19:13:20 +01:00
Daniel García
ad92692bab Merge pull request #1413 from paolobarbolini/email-clones
Remove unnecessary allocations
2021-02-20 17:58:12 +01:00
Paolo Barbolini
d956d42903 Remove unnecessary allocations 2021-02-19 20:17:18 +01:00
Daniel García
d69be7d03a Merge pull request #1389 from jjlin/alpine
Update Alpine base images to 3.13
2021-02-15 20:58:13 +01:00
Jeremy Lin
f82de8d00d Update Alpine base images to 3.13 2021-02-14 15:18:47 -08:00
Daniel García
c836f88ff2 Remove soup and use a newer html5ever directly 2021-02-07 22:28:02 +01:00
Daniel García
8b660ae090 Swap structopt for a simpler alternative 2021-02-07 20:10:40 +01:00
Daniel García
9323c57f49 Remove debug print 2021-02-07 00:22:39 +01:00
Daniel García
85e3c73525 Basic experimental ldap import support with the official directory connector 2021-02-06 20:15:42 +01:00
Daniel García
a74bc2e58f Update web vault to 2.18.1b 2021-02-06 16:49:49 +01:00
Daniel García
0680638933 Update dependencies 2021-02-06 16:49:28 +01:00
Daniel García
46d31ee5f7 Merge pull request #1356 from BlackDex/fix-config-bug
Fixed small buggy in validation
2021-02-03 23:50:49 +01:00
BlackDex
e794b397d3 Fixed small buggy in validation 2021-02-03 23:47:48 +01:00
Daniel García
d41350050b Merge pull request #1353 from BlackDex/admin-interface
Extra features for admin interface.
2021-02-03 22:50:15 +01:00
Mathijs van Veluw
4cd5b06b7f Merge branch 'master' into admin-interface 2021-02-03 22:41:59 +01:00
Daniel García
cd768439d2 Merge pull request #1329 from BlackDex/misc-updates
JSON Response updates and small fixes
2021-02-03 22:37:59 +01:00
Mathijs van Veluw
9e5fd2d576 Merge branch 'master' into admin-interface 2021-02-03 22:22:33 +01:00
Mathijs van Veluw
ecb46f591c Merge branch 'master' into misc-updates 2021-02-03 22:22:06 +01:00
Daniel García
d62d53aa8e Merge pull request #1341 from BlackDex/dep-update
Updated dependencies and small mail fixes
2021-02-03 22:19:18 +01:00
Daniel García
2c515ab13c Merge pull request #1355 from jjlin/global-domains
Sync global_domains.json with upstream
2021-02-03 22:17:57 +01:00
Jeremy Lin
83d556ff0c Sync global_domains.json to bitwarden/server@cf84453 (Disney, Sony) 2021-02-03 12:22:03 -08:00
Jeremy Lin
678d313836 global_domains.py: allow syncing to a specific Git ref 2021-02-03 12:20:44 -08:00
BlackDex
705d840ea3 Extra features for admin interface.
- Able to modify the user type per organization
- Able to remove a whole organization
- Added podman detection
- Only show web-vault update when not running a containerized
  bitwarden_rs

Solves #936
2021-02-03 18:43:54 +01:00
BlackDex
7dff8c01dd JSON Response updates and small fixes
Updated several json response models.
Also fixed a few small bugs.

ciphers.rs:
  - post_ciphers_create:
    * Prevent cipher creation to organization without a collection.
  - update_cipher_from_data:
    * ~~Fixed removal of user_uuid which prevent user-owned shared-cipher to be not editable anymore when set to read-only.~~
    * Cleanup the json_data by removing the `Response` key/values from several objects.
  - delete_all:
    * Do not delete all Collections during the Purge of an Organization (same as upstream).

cipher.rs:
  - Cipher::to_json:
    * Updated json response to match upstream.
    * Return empty json object if there is no type_data instead of values which should not be set for the type_data.

organizations.rs:
  * Added two new endpoints to prevent Javascript errors regarding tax

organization.rs:
  - Organization::to_json:
    * Updated response model to match upstream
  - UserOrganization::to_json:
    * Updated response model to match upstream

collection.rs:
  - Collection::{to_json, to_json_details}:
    * Updated the json response model, and added a detailed version used during the sync
  - hide_passwords_for_user:
    * Added this function to return if the passwords should be hidden or not for the user at the specific collection (used by `to_json_details`)

Update 1: Some small changes after comments from @jjlin.
Update 2: Fixed vault purge by user to make sure the cipher is not part of an organization.

Resolves #971
Closes #990, Closes #991
2021-01-31 21:46:37 +01:00
BlackDex
5860679624 Updated dependencies and small mail fixes
- Updated rust nightly
- Updated depenencies
- Removed unicode support for regex (less dependencies)
- Fixed dependency and nightly changes/deprications
- Some mail changes for less spam point triggering
2021-01-31 20:07:42 +01:00
Daniel García
4628e4519d Update web vault to 2.18.1 2021-01-27 16:08:11 +01:00
Mathijs van Veluw
b884fd20a1 Merge pull request #1333 from jjlin/fix-manager-access
Fix collection access issues for owner/admin users
2021-01-27 08:07:20 +01:00
Jeremy Lin
67c657003d Fix collection access issues for owner/admin users
The implementation of the `Manager` user type (#1242) introduced a regression
whereby owner/admin users are incorrectly denied access to certain collection
APIs if their access control for collections isn't set to "access all".

Owner/admin users should always have full access to collection APIs, per
https://bitwarden.com/help/article/user-types-access-control/#access-control:

> Assigning Admins and Owners to Collections via Access Control will only
> impact which Collections appear readily in the Filters section of their
> Vault. Admins and Owners will always be able to access "un-assigned"
> Collections via the Organization view.
2021-01-26 22:35:09 -08:00
Daniel García
580c1bbc7d Update web vault to 2.18.0 2021-01-25 12:27:57 +01:00
Daniel García
2b6383d243 Merge pull request #1327 from jjlin/dockerfile-cleanup
Dockerfile.j2: clean up web-vault section
2021-01-25 12:24:04 +01:00
Daniel García
f27455a26f Merge pull request #1328 from jjlin/restore-rev-date
Add cipher response to restore operations
2021-01-25 12:23:00 +01:00
Jeremy Lin
1d4f900e48 Add cipher response to restore operations
This matches changes in the upstream Bitwarden server and clients.

Upstream PR: https://github.com/bitwarden/server/pull/1072
2021-01-24 21:57:32 -08:00
Jeremy Lin
c5ca588a6f Dockerfile.j2: clean up web-vault section 2021-01-24 17:26:25 -08:00
Daniel García
06888251e3 Merge pull request #1326 from jjlin/personal-ownership
Add support for the Personal Ownership policy
2021-01-24 14:09:12 +01:00
Daniel García
1a6e4cf4e4 Merge pull request #1321 from mkilchhofer/feature/improve_shutdown_behavior
Improve shutdown behavior (on kubernetes and allow CTRL+C)
2021-01-24 14:06:15 +01:00
Jeremy Lin
9f86196a9d Add support for the Personal Ownership policy
Upstream refs:

* https://github.com/bitwarden/server/pull/1013
* https://bitwarden.com/help/article/policies/#personal-ownership
2021-01-23 20:50:06 -08:00
Marco Kilchhofer
1e31043fb3 Improve shutdown behavior (on kubernetes) 2021-01-22 11:50:24 +01:00
Daniel García
85adcf1ae5 Merge pull request #1316 from BlackDex/admin-interface
Updated the admin interface
2021-01-19 21:58:21 +01:00
Daniel García
9abb4d2873 Merge pull request #1314 from jjlin/image-labels
Add `org.opencontainers` labels to Docker images
2021-01-19 21:53:27 +01:00
BlackDex
235ff44736 Updated the admin interface
Mostly updated the admin interface, also some small other items.

- Added more diagnostic information to (hopefully) decrease issue
  reporting, or at least solve them quicker.
- Added an option to generate a support string which can be used to
  copy/paste on the forum or during the creation of an issue. It will
try to hide the sensitive information automatically.
- Changed the `Created At` and `Last Active` info to be in a column and
  able to sort them in the users overview.
- Some small layout changes.
- Updated javascript and css files to the latest versions available.
- Decreased the png file sizes using `oxipng`
- Updated target='_blank' links to have rel='noreferrer' to prevent
  javascript window.opener modifications.
2021-01-19 17:55:21 +01:00
Jeremy Lin
9c2d741749 Add org.opencontainers labels to Docker images 2021-01-18 01:10:41 -08:00
Daniel García
37cc0c34cf Merge pull request #1304 from jjlin/buildx
Use Docker Buildx for multi-arch builds
2021-01-12 21:51:33 +01:00
Jeremy Lin
5633b6ac94 Use Docker Buildx for multi-arch builds
The bitwarden_rs code is still cross-compiled exactly as before, but Docker
Buildx is used to rewrite the resulting Docker images with correct platform
metadata (reflecting the target platform instead of the build platform).
Buildx also now handles building and pushing the multi-arch manifest lists.
2021-01-09 02:33:36 -08:00
Daniel García
175f2aeace Merge pull request #1270 from BlackDex/update-ci
Updated Github Actions, Fixed Dockerfile
2020-12-17 18:22:46 +01:00
BlackDex
feefe69094 Updated Github Actions, Fixed Dockerfile
- Updated the Github Actions to build just one binary with all DB
  Backends.

- Created a hadolint workflow to check and verify Dockerfiles.
- Fixed current hadolint errors.
- Fixed a bug in the Dockerfile.j2 which prevented the correct libraries
  and tools to be installed on the Alpine images.

- Deleted travis.yml since that is not used anymore
2020-12-16 19:31:39 +01:00
Daniel García
46df3ee7cd Updated insecure ws dependency and general dep updates 2020-12-15 22:23:12 +01:00
Daniel García
bb945ad01b Merge pull request #1243 from BlackDex/fix-key-rotate
Fix Key Rotation during password change.
2020-12-14 20:56:31 +01:00
BlackDex
de86aa671e Fix Key Rotation during password change
When ticking the 'Also rotate my account's encryption key' box, the key
rotated ciphers are posted after the change of password.

During the password change the security stamp was reseted which made
the posted key's return an invalid auth. This reset is needed to prevent other clients from still being able to read/write.

This fixes this by adding a new database column which stores a stamp exception which includes the allowed route and the current security stamp before it gets reseted.
When the security stamp check fails it will check if there is a stamp exception and tries to match the route and security stamp.

Currently it only allows for one exception. But if needed we could expand it by using a Vec<UserStampException> and change the functions accordingly.

fixes #1240
2020-12-14 19:58:23 +01:00
Daniel García
e38771bbbd Merge pull request #1267 from jjlin/datetime-cleanup
Clean up datetime output and code
2020-12-14 18:36:39 +01:00
Daniel García
a3f9a8d7dc Merge pull request #1265 from jjlin/cipher-rev-date
Fix stale data check failure when cloning a cipher
2020-12-14 18:35:17 +01:00
Daniel García
4b6bc6ef66 Merge pull request #1266 from BlackDex/icon-user-agent
Small update on favicon downloading
2020-12-14 18:34:07 +01:00
Jeremy Lin
455a23361f Clean up datetime output and code
* For clarity, add `UTC` suffix for datetimes in the `Diagnostics` admin tab.
* Format datetimes in the local timezone in the `Users` admin tab.
* Refactor some datetime code and add doc comments.
2020-12-13 19:49:22 -08:00
BlackDex
1a8ec04733 Small update on favicon downloading
- Changed the user-agent, which caused at least one site to stall the
  connection (Same happens on icons.bitwarden.com)
- Added default_header creation to the lazy static CLIENT
- Added referer passing, which is checked by some sites
- Some small other changes
2020-12-10 23:13:24 +01:00
Jeremy Lin
4e60df7a08 Fix stale data check failure when cloning a cipher 2020-12-10 00:17:34 -08:00
Daniel García
219a9d9f5e Merge pull request #1262 from BlackDex/icon-fixes
Updated icon downloading.
2020-12-08 18:05:05 +01:00
BlackDex
48baf723a4 Updated icon downloading
- Added more checks to prevent panics (Removed unwrap)
- Try do download from base domain or add www when the provided domain
  fails
- Added some more domain validation checks to prevent errors
- Added the ICON_BLACKLIST_REGEX to a Lazy Static HashMap which
  speeds-up the checks!
- Validate the Regex before starting/config change.
- Some cleanups
- Disabled some noisy debugging from 2 crates.
2020-12-08 17:34:18 +01:00
Daniel García
6530904883 Update web vault version to 2.17.1 2020-12-08 16:43:19 +01:00
Daniel García
d15d24f4ff Merge pull request #1242 from BlackDex/allow-manager-role
Adding Manager Role support
2020-12-08 16:11:55 +01:00
Daniel García
8d992d637e Merge pull request #1257 from jjlin/cipher-rev-date
Validate cipher updates with revision date
2020-12-08 15:59:21 +01:00
Daniel García
6ebc83c3b7 Merge pull request #1247 from janost/admin-disable-user
Implement admin ability to enable/disable users
2020-12-08 15:43:56 +01:00
Daniel García
b32f4451ee Merge branch 'master' into admin-disable-user 2020-12-08 15:42:37 +01:00
Daniel García
99142c7552 Merge pull request #1252 from BlackDex/update-dependencies-20201203
Updated dependencies and Dockerfiles
2020-12-08 15:33:41 +01:00
Daniel García
db710bb931 Merge pull request #1245 from janost/user-last-login
Show last active it on admin users page
2020-12-08 15:31:25 +01:00
Jeremy Lin
a9e9a397d8 Validate cipher updates with revision date
Prevent clients from updating a cipher if the local copy is stale.
Validation is only performed when the client provides its last known
revision date; this date isn't provided when using older clients,
or when the operation doesn't involve updating an existing cipher.

Upstream PR: https://github.com/bitwarden/server/pull/994
2020-12-07 19:34:00 -08:00
BlackDex
d46a6ac687 Updated dependencies and Dockerfiles
- Updated crates
- Updated rust-toolchain
- Updated Dockerfile to use latest rust 1.48 version
- Updated AMD64 Alpine to use same version as rust-toolchain and support
  PostgreSQL.
- Updated Rocket to the commit right before they updated hyper.
  Until that update there were some crates updated and some small fixes.
  After that build fails and we probably need to make some changes
(which is probably something already done in the async branch)
2020-12-04 13:38:42 +01:00
janost
1eb5495802 Show latest active device as last active on admin page 2020-12-03 17:07:32 +01:00
BlackDex
7cf8809d77 Adding Manager Role support
This has been requested a few times (#1136 & #246 & forum), and there already were two
(1:1 duplicate) PR's (#1222 & #1223) which needed some changes and no
followups or further comments unfortunally.

This PR adds two auth headers.
- ManagerHeaders
  Checks if the user-type is Manager or higher and if the manager is
part of that collection or not.
- ManagerHeadersLoose
  Check if the user-type is Manager or higher, but does not check if the
user is part of the collection, needed for a few features like
retreiving all the users of an org.

I think this is the safest way to implement this instead of having to
check this within every function which needs this manually.

Also some extra checks if a manager has access to all collections or
just a selection.

fixes #1136
2020-12-02 22:50:51 +01:00
janost
043aa27aa3 Implement admin ability to enable/disable users 2020-11-30 23:12:56 +01:00
Daniel García
9824d94a1c Merge pull request #1244 from janost/read-config-from-files
Read config vars from files
2020-11-29 15:28:13 +01:00
janost
e8ef76b8f9 Read config vars from files 2020-11-29 02:31:49 +01:00
Daniel García
be1ddb4203 Merge pull request #1234 from janost/fix-failed-auth-log
Log proper namespace in the err!() macro
2020-11-27 18:49:46 +01:00
janost
caddf21fca Log proper namespace in the err!() macro 2020-11-22 00:09:45 +01:00
Daniel García
5379329ef7 Merge pull request #1229 from BlackDex/email-fixes
Email fixes
2020-11-18 16:16:27 +01:00
BlackDex
6faaeaae66 Updated email processing.
- Added an option to enable smtp debugging via SMTP_DEBUG. This will
  trigger a trace of the smtp commands sent/received to/from the mail
server. Useful when troubleshooting.
- Added two options to ignore invalid certificates which either do not
  match at all, or only doesn't match the hostname.
- Updated lettre to the latest alpha.4 version.
2020-11-18 12:07:08 +01:00
BlackDex
3fed323385 Fixed plain/text email format
plain/text emails should not contain html elements like <p> <a> etc..
This triggers some spamfilters and increases the spam score.

Also added the github link into the text only emails since this also
triggers spamfilters to increase the score since the url/link count is
different between the multipart messages.
2020-11-18 12:04:16 +01:00
BlackDex
58a928547d Updated admin settings page.
- Added check if settings are changed but not saved when sending test
  email.
- Added some styling to emphasize some risks settings.
- Fixed alignment of elements when the label has multiple lines.
2020-11-18 12:00:25 +01:00
Daniel García
558410c5bd Merge pull request #1220 from jameshurst/master
Return 404 instead of fallback icon
2020-11-14 14:17:53 +01:00
Daniel García
0dc0decaa7 Merge pull request #1212 from BlackDex/dotenv-warnings
Added error handling during dotenv loading
2020-11-14 14:11:56 +01:00
BlackDex
d11d663c5c Added error handling during dotenv loading
Some issue people report are because of misconfiguration or bad .env
files. To mittigate this i added error handling for this.

- Panic/Quit on a LineParse error, which indicates bad .env file format.
- Emits a info message when there is no .env file found.
- Emits a warning message when there is a .env file, but not no
  permissions.
- Emits a warning on every other message not specifically catched.
2020-11-12 13:40:26 +01:00
James Hurst
771233176f Fix for negcached icons 2020-11-09 22:06:11 -05:00
James Hurst
ed70b07d81 Return 404 instead of fallback icon 2020-11-09 20:47:26 -05:00
Daniel García
e25fc7083d Merge pull request #1219 from aveao/master
Ensure that a user is actually in an org when applying policies
2020-11-07 23:29:12 +01:00
Ave
fa364c3f2c Ensure that a user is actually in an org when applying policies 2020-11-08 01:14:17 +03:00
Daniel García
b5f9fe4d3b Fix #1206 2020-11-07 23:03:02 +01:00
Daniel García
013d4c28b2 Try to fix #1218 2020-11-07 23:01:56 +01:00
Daniel García
63acc8619b Update dependencies 2020-11-07 23:01:04 +01:00
Daniel García
ec920b5756 Merge pull request #1199 from jjlin/delete-admin
Add missing admin endpoints for deleting ciphers
2020-10-23 14:18:41 +02:00
Jeremy Lin
95caaf2a40 Add missing admin endpoints for deleting ciphers
This fixes the inability to bulk-delete ciphers from org vault views.
2020-10-23 03:42:22 -07:00
Mathijs van Veluw
7099f8bee8 Merge pull request #1198 from fabianvansteen/patch-1
Correction of verify_email error message
2020-10-23 11:40:39 +02:00
Fabian van Steen
b41a0d840c Correction of verify_email error message 2020-10-23 10:30:25 +02:00
Daniel García
c577ade90e Updated dependencies 2020-10-15 23:44:35 +02:00
Daniel García
257b143df1 Remove some duplicate code in Dockerfile with the help of some variables 2020-10-11 17:27:15 +02:00
Daniel García
34ee326ce9 Merge pull request #1178 from BlackDex/update-azure-pipelines
Updated the azure-pipelines.yml for multidb
2020-10-11 17:25:15 +02:00
Daniel García
090104ce1b Merge pull request #1181 from BlackDex/update-issue-template
Updated bug-report to note to update first
2020-10-11 17:24:42 +02:00
BlackDex
3305d5dc92 Updated bug-report to note to update first 2020-10-11 15:58:31 +02:00
BlackDex
5bdcfe128d Updated the azure-pipelines.yml for multidb
Updated the azure-pipelines.yml to build multidb now.
- Updated to Ubuntu 18.04 (Closer matches the docker builds)
- Added some really needed apt packages to be sure that they are
  installed
- Now run cargo test using all database backeds in one go.
2020-10-08 18:48:05 +02:00
162 changed files with 18535 additions and 14032 deletions

View File

@@ -4,6 +4,8 @@ target
# Data folder # Data folder
data data
.env .env
.env.template
.gitattributes
# IDE files # IDE files
.vscode .vscode

23
.editorconfig Normal file
View File

@@ -0,0 +1,23 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
end_of_line = lf
charset = utf-8
[*.{rs,py}]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -1,4 +1,4 @@
## Bitwarden_RS Configuration File ## Vaultwarden Configuration File
## Uncomment any of the following lines to change the defaults ## Uncomment any of the following lines to change the defaults
## ##
## Be aware that most of these settings will be overridden if they were changed ## Be aware that most of these settings will be overridden if they were changed
@@ -28,6 +28,7 @@
# RSA_KEY_FILENAME=data/rsa_key # RSA_KEY_FILENAME=data/rsa_key
# ICON_CACHE_FOLDER=data/icon_cache # ICON_CACHE_FOLDER=data/icon_cache
# ATTACHMENTS_FOLDER=data/attachments # ATTACHMENTS_FOLDER=data/attachments
# SENDS_FOLDER=data/sends
## Templates data folder, by default uses embedded templates ## Templates data folder, by default uses embedded templates
## Check source code to see the format ## Check source code to see the format
@@ -35,9 +36,9 @@
## Automatically reload the templates for every request, slow, use only for development ## Automatically reload the templates for every request, slow, use only for development
# RELOAD_TEMPLATES=false # RELOAD_TEMPLATES=false
## Client IP Header, used to identify the IP of the client, defaults to "X-Client-IP" ## Client IP Header, used to identify the IP of the client, defaults to "X-Real-IP"
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP ## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Client-IP # IP_HEADER=X-Real-IP
## Cache time-to-live for successfully obtained icons, in seconds (0 is "forever") ## Cache time-to-live for successfully obtained icons, in seconds (0 is "forever")
# ICON_CACHE_TTL=2592000 # ICON_CACHE_TTL=2592000
@@ -55,6 +56,28 @@
# WEBSOCKET_ADDRESS=0.0.0.0 # WEBSOCKET_ADDRESS=0.0.0.0
# WEBSOCKET_PORT=3012 # WEBSOCKET_PORT=3012
## Controls whether users are allowed to create Bitwarden Sends.
## This setting applies globally to all users.
## To control this on a per-org basis instead, use the "Disable Send" org policy.
# SENDS_ALLOWED=true
## Job scheduler settings
##
## Job schedules use a cron-like syntax (as parsed by https://crates.io/crates/cron),
## and are always in terms of UTC time (regardless of your local time zone settings).
##
## How often (in ms) the job scheduler thread checks for jobs that need running.
## Set to 0 to globally disable scheduled jobs.
# JOB_POLL_INTERVAL_MS=30000
##
## Cron schedule of the job that checks for Sends past their deletion date.
## Defaults to hourly (5 minutes after the hour). Set blank to disable this job.
# SEND_PURGE_SCHEDULE="0 5 * * * *"
##
## Cron schedule of the job that checks for trashed items to delete permanently.
## Defaults to daily (5 minutes after midnight). Set blank to disable this job.
# TRASH_PURGE_SCHEDULE="0 5 0 * * *"
## Enable extended logging, which shows timestamps and targets in the logs ## Enable extended logging, which shows timestamps and targets in the logs
# EXTENDED_LOGGING=true # EXTENDED_LOGGING=true
@@ -81,9 +104,9 @@
## Enable WAL for the DB ## Enable WAL for the DB
## Set to false to avoid enabling WAL during startup. ## Set to false to avoid enabling WAL during startup.
## Note that if the DB already has WAL enabled, you will also need to disable WAL in the DB, ## Note that if the DB already has WAL enabled, you will also need to disable WAL in the DB,
## this setting only prevents bitwarden_rs from automatically enabling it on start. ## this setting only prevents vaultwarden from automatically enabling it on start.
## Please read project wiki page about this setting first before changing the value as it can ## Please read project wiki page about this setting first before changing the value as it can
## cause performance degradation or might render the service unable to start. ## cause performance degradation or might render the service unable to start.
# ENABLE_DB_WAL=true # ENABLE_DB_WAL=true
## Database connection retries ## Database connection retries
@@ -104,7 +127,8 @@
## Icon blacklist Regex ## Icon blacklist Regex
## Any domains or IPs that match this regex won't be fetched by the icon service. ## Any domains or IPs that match this regex won't be fetched by the icon service.
## Useful to hide other servers in the local network. Check the WIKI for more details ## Useful to hide other servers in the local network. Check the WIKI for more details
# ICON_BLACKLIST_REGEX=192\.168\.1\.[0-9].*^ ## NOTE: Always enclose this regex withing single quotes!
# ICON_BLACKLIST_REGEX='^(192\.168\.0\.[0-9]+|192\.168\.1\.[0-9]+)$'
## Any IP which is not defined as a global IP will be blacklisted. ## Any IP which is not defined as a global IP will be blacklisted.
## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block ## Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block
@@ -168,7 +192,7 @@
## Invitations org admins to invite users, even when signups are disabled ## Invitations org admins to invite users, even when signups are disabled
# INVITATIONS_ALLOWED=true # INVITATIONS_ALLOWED=true
## Name shown in the invitation emails that don't come from a specific organization ## Name shown in the invitation emails that don't come from a specific organization
# INVITATION_ORG_NAME=Bitwarden_RS # INVITATION_ORG_NAME=Vaultwarden
## Per-organization attachment limit (KB) ## Per-organization attachment limit (KB)
## Limit in kilobytes for an organization attachments, once the limit is exceeded it won't be possible to upload more ## Limit in kilobytes for an organization attachments, once the limit is exceeded it won't be possible to upload more
@@ -177,6 +201,10 @@
## Limit in kilobytes for a users attachments, once the limit is exceeded it won't be possible to upload more ## Limit in kilobytes for a users attachments, once the limit is exceeded it won't be possible to upload more
# USER_ATTACHMENT_LIMIT= # USER_ATTACHMENT_LIMIT=
## Number of days to wait before auto-deleting a trashed item.
## If unset (the default), trashed items are not auto-deleted.
## This setting applies globally, so make sure to inform all users of any changes to this setting.
# TRASH_AUTO_DELETE_DAYS=
## Controls the PBBKDF password iterations to apply on the server ## Controls the PBBKDF password iterations to apply on the server
## The change only applies when the password is changed ## The change only applies when the password is changed
@@ -228,23 +256,24 @@
## You can disable this, so that only the current TOTP Code is allowed. ## You can disable this, so that only the current TOTP Code is allowed.
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid. ## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid. ## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
# AUTHENTICATOR_DISABLE_TIME_DRIFT = false # AUTHENTICATOR_DISABLE_TIME_DRIFT=false
## Rocket specific settings, check Rocket documentation to learn more ## Rocket specific settings
# ROCKET_ENV=staging ## See https://rocket.rs/v0.4/guide/configuration/ for more details.
# ROCKET_ADDRESS=0.0.0.0 # Enable this to test mobile app # ROCKET_ADDRESS=0.0.0.0
# ROCKET_PORT=8000 # ROCKET_PORT=80 # Defaults to 80 in the Docker images, or 8000 otherwise.
# ROCKET_WORKERS=10
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"} # ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
## Mail specific settings, set SMTP_HOST and SMTP_FROM to enable the mail service. ## Mail specific settings, set SMTP_HOST and SMTP_FROM to enable the mail service.
## To make sure the email links are pointing to the correct host, set the DOMAIN variable. ## To make sure the email links are pointing to the correct host, set the DOMAIN variable.
## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory ## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory
# SMTP_HOST=smtp.domain.tld # SMTP_HOST=smtp.domain.tld
# SMTP_FROM=bitwarden-rs@domain.tld # SMTP_FROM=vaultwarden@domain.tld
# SMTP_FROM_NAME=Bitwarden_RS # SMTP_FROM_NAME=Vaultwarden
# SMTP_PORT=587 # SMTP_PORT=587 # Ports 587 (submission) and 25 (smtp) are standard without encryption and with encryption via STARTTLS (Explicit TLS). Port 465 is outdated and used with Implicit TLS.
# SMTP_SSL=true # (Explicit) - This variable by default configures Explicit STARTTLS, it will upgrade an insecure connection to a secure one. Unless SMTP_EXPLICIT_TLS is set to true. # SMTP_SSL=true # (Explicit) - This variable by default configures Explicit STARTTLS, it will upgrade an insecure connection to a secure one. Unless SMTP_EXPLICIT_TLS is set to true. Either port 587 or 25 are default.
# SMTP_EXPLICIT_TLS=true # (Implicit) - N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851) - SMTP_SSL Needs to be set to true for this option to work. # SMTP_EXPLICIT_TLS=true # (Implicit) - N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851) - SMTP_SSL Needs to be set to true for this option to work. Usually port 465 is used here.
# SMTP_USERNAME=username # SMTP_USERNAME=username
# SMTP_PASSWORD=password # SMTP_PASSWORD=password
# SMTP_TIMEOUT=15 # SMTP_TIMEOUT=15
@@ -259,6 +288,22 @@
## but might need to be changed in case it trips some anti-spam filters ## but might need to be changed in case it trips some anti-spam filters
# HELO_NAME= # HELO_NAME=
## SMTP debugging
## When set to true this will output very detailed SMTP messages.
## WARNING: This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
# SMTP_DEBUG=false
## Accept Invalid Hostnames
## DANGEROUS: This option introduces significant vulnerabilities to man-in-the-middle attacks!
## Only use this as a last resort if you are not able to use a valid certificate.
# SMTP_ACCEPT_INVALID_HOSTNAMES=false
## Accept Invalid Certificates
## DANGEROUS: This option introduces significant vulnerabilities to man-in-the-middle attacks!
## Only use this as a last resort if you are not able to use a valid certificate.
## If the Certificate is valid but the hostname doesn't match, please use SMTP_ACCEPT_INVALID_HOSTNAMES instead.
# SMTP_ACCEPT_INVALID_CERTS=false
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
## If sending the email fails the login attempt will fail!! ## If sending the email fails the login attempt will fail!!
# REQUIRE_DEVICE_EMAIL=false # REQUIRE_DEVICE_EMAIL=false

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Ignore vendored scripts in GitHub stats
src/static/scripts/* linguist-vendored

View File

@@ -1,42 +1,66 @@
--- ---
name: Bug report name: Bug report
about: Create a report to help us improve 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: '' title: ''
labels: '' labels: ''
assignees: '' 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. 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 unneccessary for your issue, feel free to remove them. This is only a guideline. If you think that parts are unnecessary for your issue, feel free to remove them.
Remember to hide/obfuscate personal and confidential information, Remember to hide/redact personal or confidential information,
such as names, global IP/DNS adresses and especially passwords, if neccessary. such as passwords, IP addresses, and DNS names as appropriate.
--> -->
### Subject of the issue ### Subject of the issue
<!-- Describe your issue here.--> <!-- 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) -->
### Your environment
<!-- The version number, obtained from the logs or the admin page -->
* Bitwarden_rs version:
<!-- How the server was installed: Docker image / package / built from source -->
* Install method:
* Clients used: <!-- if applicable -->
* Reverse proxy and version: <!-- if applicable --> * Reverse proxy and version: <!-- if applicable -->
* Version of mysql/postgresql: <!-- if applicable -->
* Other relevant information: * MySQL/MariaDB or PostgreSQL version: <!-- if applicable -->
* Other relevant details:
### Steps to reproduce ### Steps to reproduce
<!-- Tell us how to reproduce this issue. What parameters did you set (differently from the defaults) <!-- Tell us how to reproduce this issue. What parameters did you set (differently from the defaults)
and how did you start bitwarden_rs? --> and how did you start vaultwarden? -->
### Expected behaviour ### Expected behaviour
<!-- Tell us what should happen --> <!-- Tell us what you expected to happen -->
### Actual behaviour ### Actual behaviour
<!-- Tell us what happens instead --> <!-- Tell us what actually happened -->
### Relevant logs ### Troubleshooting data
<!-- Share some logfiles, screenshots or output of relevant programs with us. --> <!-- Share any log files, screenshots, or other relevant troubleshooting data -->

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +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
url: https://github.com/dani-garcia/vaultwarden/discussions
about: An alternative to the Discourse forum, if this is easier for you.

View File

@@ -1,11 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: better for forum
assignees: ''
---
# Please submit all your feature requests to the forum
Link: https://bitwardenrs.discourse.group/c/feature-requests

View File

@@ -1,11 +0,0 @@
---
name: Help with installation/configuration
about: Any questions about the setup of bitwarden_rs
title: ''
labels: better for forum
assignees: ''
---
# Please submit all your third party help requests to the forum
Link: https://bitwardenrs.discourse.group/c/help

View File

@@ -1,11 +0,0 @@
---
name: Help with proxy/database/NAS setup
about: Any questions about third party software
title: ''
labels: better for forum
assignees: ''
---
# Please submit all your third party help requests to the forum
Link: https://bitwardenrs.discourse.group/c/third-party-help

BIN
.github/security-contact.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

181
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,181 @@
name: Build
on:
push:
paths-ignore:
- "*.md"
- "*.txt"
- ".dockerignore"
- ".env.template"
- ".gitattributes"
- ".gitignore"
- "azure-pipelines.yml"
- "docker/**"
- "hooks/**"
- "tools/**"
- ".github/FUNDING.yml"
- ".github/ISSUE_TEMPLATE/**"
- ".github/security-contact.gif"
pull_request:
# Ignore when there are only changes done too one of these paths
paths-ignore:
- "*.md"
- "*.txt"
- ".dockerignore"
- ".env.template"
- ".gitattributes"
- ".gitignore"
- "azure-pipelines.yml"
- "docker/**"
- "hooks/**"
- "tools/**"
- ".github/FUNDING.yml"
- ".github/ISSUE_TEMPLATE/**"
- ".github/security-contact.gif"
jobs:
build:
# 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"
strategy:
fail-fast: false
matrix:
channel:
- nightly
# - stable
target-triple:
- x86_64-unknown-linux-gnu
# - x86_64-unknown-linux-musl
include:
- target-triple: x86_64-unknown-linux-gnu
host-triple: x86_64-unknown-linux-gnu
features: [sqlite,mysql,postgresql] # Remember to update the `cargo test` to match the amount of features
channel: nightly
os: ubuntu-18.04
ext: ""
# - target-triple: x86_64-unknown-linux-gnu
# host-triple: x86_64-unknown-linux-gnu
# features: "sqlite,mysql,postgresql"
# channel: stable
# os: ubuntu-18.04
# ext: ""
name: Building ${{ matrix.channel }}-${{ matrix.target-triple }}
runs-on: ${{ matrix.os }}
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v2
# End Checkout the repo
# Install musl-tools when needed
- name: Install musl tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-dev musl-tools cmake
if: matrix.target-triple == 'x86_64-unknown-linux-musl'
# End Install musl-tools when needed
# Install dependencies
- name: Install dependencies Ubuntu
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl sqlite build-essential libmariadb-dev-compat libpq-dev libssl-dev pkgconf
if: startsWith( matrix.os, 'ubuntu' )
# End Install dependencies
# Enable Rust Caching
- uses: Swatinem/rust-cache@v1
# End Enable Rust Caching
# Uses the rust-toolchain file to determine version
- name: 'Install ${{ matrix.channel }}-${{ matrix.host-triple }} for target: ${{ matrix.target-triple }}'
uses: actions-rs/toolchain@v1
with:
profile: minimal
target: ${{ matrix.target-triple }}
components: clippy, rustfmt
# End Uses the rust-toolchain file to determine version
# Run cargo tests (In release mode to speed up future builds)
# First test all features together, afterwards test them separately.
- name: "`cargo test --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: test
args: --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }}
# Test single features
# 0: sqlite
- name: "`cargo test --release --features ${{ matrix.features[0] }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: test
args: --release --features ${{ matrix.features[0] }} --target ${{ matrix.target-triple }}
if: ${{ matrix.features[0] != '' }}
# 1: mysql
- name: "`cargo test --release --features ${{ matrix.features[1] }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: test
args: --release --features ${{ matrix.features[1] }} --target ${{ matrix.target-triple }}
if: ${{ matrix.features[1] != '' }}
# 2: postgresql
- name: "`cargo test --release --features ${{ matrix.features[2] }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: test
args: --release --features ${{ matrix.features[2] }} --target ${{ matrix.target-triple }}
if: ${{ matrix.features[2] != '' }}
# End Run cargo tests
# Run cargo clippy, and fail on warnings (In release mode to speed up future builds)
- name: "`cargo clippy --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: clippy
args: --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }} -- -D warnings
# End Run cargo clippy
# Run cargo fmt
- name: '`cargo fmt`'
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
# End Run cargo fmt
# Build the binary
- name: "`cargo build --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }}`"
uses: actions-rs/cargo@v1
with:
command: build
args: --release --features ${{ join(matrix.features, ',') }} --target ${{ matrix.target-triple }}
# End Build the binary
# Upload artifact to Github Actions
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: vaultwarden-${{ matrix.target-triple }}${{ matrix.ext }}
path: target/${{ matrix.target-triple }}/release/vaultwarden${{ matrix.ext }}
# End Upload artifact to Github Actions
## This is not used at the moment
## We could start using this when we can build static binaries
# Upload to github actions release
# - name: Release
# uses: Shopify/upload-to-release@1
# if: startsWith(github.ref, 'refs/tags/')
# with:
# name: vaultwarden-${{ matrix.target-triple }}${{ matrix.ext }}
# path: target/${{ matrix.target-triple }}/release/vaultwarden${{ matrix.ext }}
# repo-token: ${{ secrets.GITHUB_TOKEN }}
# End Upload to github actions release

38
.github/workflows/hadolint.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Hadolint
on:
push:
# Ignore when there are only changes done too one of these paths
paths:
- "docker/**"
pull_request:
# Ignore when there are only changes done too one of these paths
paths:
- "docker/**"
jobs:
hadolint:
name: Validate Dockerfile syntax
runs-on: ubuntu-20.04
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v2
# End Checkout the repo
# Download hadolint
- name: Download hadolint
shell: bash
run: |
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
sudo chmod +x /usr/local/bin/hadolint
env:
HADOLINT_VERSION: 2.5.0
# End Download hadolint
# Test Dockerfiles
- name: Run hadolint
shell: bash
run: git ls-files --exclude='docker/*/Dockerfile*' --ignored --cached | xargs hadolint
# End Test Dockerfiles

View File

@@ -1,148 +0,0 @@
name: Workflow
on:
push:
paths-ignore:
- "**.md"
#pull_request:
# paths-ignore:
# - "**.md"
jobs:
build:
name: Build
strategy:
fail-fast: false
matrix:
db-backend: [sqlite, mysql, postgresql]
target:
- x86_64-unknown-linux-gnu
# - x86_64-unknown-linux-musl
# - x86_64-apple-darwin
# - x86_64-pc-windows-msvc
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
ext:
# - target: x86_64-unknown-linux-musl
# os: ubuntu-latest
# ext:
# - target: x86_64-apple-darwin
# os: macOS-latest
# ext:
# - target: x86_64-pc-windows-msvc
# os: windows-latest
# ext: .exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v1
# - name: Cache choco cache
# uses: actions/cache@v1.0.3
# if: matrix.os == 'windows-latest'
# with:
# path: ~\AppData\Local\Temp\chocolatey
# key: ${{ runner.os }}-choco-cache-${{ matrix.db-backend }}
- name: Cache vcpkg installed
uses: actions/cache@v1.0.3
if: matrix.os == 'windows-latest'
with:
path: $VCPKG_ROOT/installed
key: ${{ runner.os }}-vcpkg-cache-${{ matrix.db-backend }}
env:
VCPKG_ROOT: 'C:\vcpkg'
- name: Cache vcpkg downloads
uses: actions/cache@v1.0.3
if: matrix.os == 'windows-latest'
with:
path: $VCPKG_ROOT/downloads
key: ${{ runner.os }}-vcpkg-cache-${{ matrix.db-backend }}
env:
VCPKG_ROOT: 'C:\vcpkg'
# - name: Cache homebrew
# uses: actions/cache@v1.0.3
# if: matrix.os == 'macOS-latest'
# with:
# path: ~/Library/Caches/Homebrew
# key: ${{ runner.os }}-brew-cache
# - name: Cache apt
# uses: actions/cache@v1.0.3
# if: matrix.os == 'ubuntu-latest'
# with:
# path: /var/cache/apt/archives
# key: ${{ runner.os }}-apt-cache
# Install dependencies
- name: Install dependencies macOS
run: brew update; brew install openssl sqlite libpq mysql
if: matrix.os == 'macOS-latest'
- name: Install dependencies Ubuntu
run: sudo apt-get update && sudo apt-get install --no-install-recommends openssl sqlite libpq-dev libmysql++-dev
if: matrix.os == 'ubuntu-latest'
- name: Install dependencies Windows
run: vcpkg integrate install; vcpkg install sqlite3:x64-windows openssl:x64-windows libpq:x64-windows libmysql:x64-windows
if: matrix.os == 'windows-latest'
env:
VCPKG_ROOT: 'C:\vcpkg'
# End Install dependencies
# Install rust nightly toolchain
- name: Cache cargo registry
uses: actions/cache@v1.0.3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-${{matrix.db-backend}}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v1.0.3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-${{matrix.db-backend}}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1.0.3
with:
path: target
key: ${{ runner.os }}-${{matrix.db-backend}}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Install latest nightly
uses: actions-rs/toolchain@v1.0.5
with:
# Uses rust-toolchain to determine version
profile: minimal
target: ${{ matrix.target }}
# Build
- name: Build Win
if: matrix.os == 'windows-latest'
run: cargo.exe build --features ${{ matrix.db-backend }} --release --target ${{ matrix.target }}
env:
RUSTFLAGS: -Ctarget-feature=+crt-static
VCPKG_ROOT: 'C:\vcpkg'
- name: Build macOS / Ubuntu
if: matrix.os == 'macOS-latest' || matrix.os == 'ubuntu-latest'
run: cargo build --verbose --features ${{ matrix.db-backend }} --release --target ${{ matrix.target }}
# Test
- name: Run tests
run: cargo test --features ${{ matrix.db-backend }}
# Upload & Release
- name: Upload artifact
uses: actions/upload-artifact@v1.0.0
with:
name: bitwarden_rs-${{ matrix.db-backend }}-${{ matrix.target }}${{ matrix.ext }}
path: target/${{ matrix.target }}/release/bitwarden_rs${{ matrix.ext }}
- name: Release
uses: Shopify/upload-to-release@1.0.0
if: startsWith(github.ref, 'refs/tags/')
with:
name: bitwarden_rs-${{ matrix.db-backend }}-${{ matrix.target }}${{ matrix.ext }}
path: target/${{ matrix.target }}/release/bitwarden_rs${{ matrix.ext }}
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,21 +0,0 @@
dist: xenial
env:
global:
- HADOLINT_VERSION=1.17.1
language: rust
rust: nightly
cache: cargo
before_install:
- sudo curl -L https://github.com/hadolint/hadolint/releases/download/v$HADOLINT_VERSION/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint
- sudo chmod +rx /usr/local/bin/hadolint
- rustup set profile minimal
# Nothing to install
install: true
script:
- git ls-files --exclude='Dockerfile*' --ignored | xargs --max-lines=1 hadolint
- cargo test --features "sqlite"
- cargo test --features "mysql"

1925
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "bitwarden_rs" name = "vaultwarden"
version = "1.0.0" version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"] authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
edition = "2018" edition = "2018"
repository = "https://github.com/dani-garcia/bitwarden_rs" repository = "https://github.com/dani-garcia/vaultwarden"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-only" license = "GPL-3.0-only"
publish = false publish = false
@@ -28,110 +28,130 @@ syslog = "4.0.1"
[dependencies] [dependencies]
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed. # Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
rocket = { version = "0.5.0-dev", features = ["tls"], default-features = false } rocket = { version = "=0.5.0-dev", features = ["tls"], default-features = false }
rocket_contrib = "0.5.0-dev" rocket_contrib = "=0.5.0-dev"
# HTTP client # HTTP client
reqwest = { version = "0.10.8", features = ["blocking", "json"] } reqwest = { version = "0.11.4", features = ["blocking", "json", "gzip", "brotli", "socks", "cookies"] }
# Used for custom short lived cookie jar
cookie = "0.15.0"
cookie_store = "0.15.0"
bytes = "1.0.1"
url = "2.2.2"
# multipart/form-data support # multipart/form-data support
multipart = { version = "0.17.0", features = ["server"], default-features = false } multipart = { version = "0.18.0", features = ["server"], default-features = false }
# WebSockets library # WebSockets library
ws = "0.9.1" ws = { version = "0.10.0", package = "parity-ws" }
# MessagePack library # MessagePack library
rmpv = "0.4.5" rmpv = "0.4.7"
# Concurrent hashmap implementation # Concurrent hashmap implementation
chashmap = "2.2.2" chashmap = "2.2.2"
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = "1.0.115" serde = { version = "1.0.126", features = ["derive"] }
serde_derive = "1.0.115" serde_json = "1.0.64"
serde_json = "1.0.57"
# Logging # Logging
log = "0.4.11" log = "0.4.14"
fern = { version = "0.6.0", features = ["syslog-4"] } fern = { version = "0.6.0", features = ["syslog-4"] }
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "1.4.5", features = [ "chrono", "r2d2"] } diesel = { version = "1.4.7", features = [ "chrono", "r2d2"] }
diesel_migrations = "1.4.0" diesel_migrations = "1.4.0"
# Bundled SQLite # Bundled SQLite
libsqlite3-sys = { version = "0.18.0", features = ["bundled"], optional = true } libsqlite3-sys = { version = "0.22.2", features = ["bundled"], optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.7.3" rand = "0.8.4"
ring = "0.16.15" ring = "0.16.20"
# UUID generation # UUID generation
uuid = { version = "0.8.1", features = ["v4"] } uuid = { version = "0.8.2", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = "0.4.15" chrono = { version = "0.4.19", features = ["serde"] }
chrono-tz = "0.5.3" chrono-tz = "0.5.3"
time = "0.2.18" time = "0.2.27"
# Job scheduler
job_scheduler = "1.2.1"
# TOTP library # TOTP library
oath = "0.10.2" oath = "0.10.2"
# Data encoding library # Data encoding library
data-encoding = "2.3.0" data-encoding = "2.3.2"
# JWT library # JWT library
jsonwebtoken = "7.2.0" jsonwebtoken = "7.2.0"
# U2F library # U2F library
u2f = "0.2.0" u2f = "0.2.0"
webauthn-rs = "0.3.0-alpha.7"
# Yubico Library # Yubico Library
yubico = { version = "0.9.1", features = ["online-tokio"], default-features = false } yubico = { version = "0.10.0", features = ["online-tokio"], default-features = false }
# A `dotenv` implementation for Rust # A `dotenv` implementation for Rust
dotenv = { version = "0.15.0", default-features = false } dotenv = { version = "0.15.0", default-features = false }
# Lazy initialization # Lazy initialization
once_cell = "1.4.1" once_cell = "1.8.0"
# Numerical libraries # Numerical libraries
num-traits = "0.2.12" num-traits = "0.2.14"
num-derive = "0.3.2" num-derive = "0.3.3"
# Email libraries # Email libraries
lettre = { version = "0.10.0-alpha.2", features = ["smtp-transport", "builder", "serde", "native-tls", "hostname"], default-features = false } tracing = { version = "0.1.26", features = ["log"] } # Needed to have lettre trace logging used when SMTP_DEBUG is enabled.
newline-converter = "0.1.0" lettre = { version = "0.10.0-rc.3", features = ["smtp-transport", "builder", "serde", "native-tls", "hostname", "tracing"], default-features = false }
# Template library # Template library
handlebars = { version = "3.4.0", features = ["dir_source"] } handlebars = { version = "4.0.1", features = ["dir_source"] }
# For favicon extraction from main website # For favicon extraction from main website
soup = "0.5.0" html5ever = "0.25.1"
regex = "1.3.9" markup5ever_rcdom = "0.1.0"
regex = { version = "1.5.4", features = ["std", "perf"], default-features = false }
data-url = "0.1.0" data-url = "0.1.0"
# Used by U2F, JWT and Postgres # Used by U2F, JWT and Postgres
openssl = "0.10.30" openssl = "0.10.34"
# URL encoding library # URL encoding library
percent-encoding = "2.1.0" percent-encoding = "2.1.0"
# Punycode conversion # Punycode conversion
idna = "0.2.0" idna = "0.2.3"
# CLI argument parsing # CLI argument parsing
structopt = "0.3.17" pico-args = "0.4.2"
# Logging panics to logfile instead stderr only # Logging panics to logfile instead stderr only
backtrace = "0.3.50" backtrace = "0.3.60"
# Macro ident concatenation # Macro ident concatenation
paste = "1.0.0" paste = "1.0.5"
[patch.crates-io] [patch.crates-io]
# Use newest ring # Use newest ring
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' } rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '263e39b5b429de1913ce7e3036575a7b4d88b6d7' }
rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' } rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = '263e39b5b429de1913ce7e3036575a7b4d88b6d7' }
# For favicon extraction from main website # For favicon extraction from main website
data-url = { git = 'https://github.com/servo/rust-url', package="data-url", rev = '7f1bd6ce1c2fde599a757302a843a60e714c5f72' } data-url = { git = 'https://github.com/servo/rust-url', package="data-url", rev = 'eb7330b5296c0d43816d1346211b74182bb4ae37' }
# The maintainer of the `job_scheduler` crate doesn't seem to have responded
# to any issues or PRs for almost a year (as of April 2021). This hopefully
# temporary fork updates Cargo.toml to use more up-to-date dependencies.
# In particular, `cron` has since implemented parsing of some common syntax
# that wasn't previously supported (https://github.com/zslayton/cron/pull/64).
job_scheduler = { git = 'https://github.com/jjlin/job_scheduler', rev = 'ee023418dbba2bfe1e30a5fd7d937f9e33739806' }
# Add support for U2F appid extension compatibility
webauthn-rs = { git = 'https://github.com/kanidm/webauthn-rs', rev = '02a99f534127b30c6f4df7f2d42bc24f76dc4211' }

View File

@@ -1,15 +1,16 @@
### This is a Bitwarden server API implementation written in Rust 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. ### 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.
--- ---
[![Travis Build Status](https://travis-ci.org/dani-garcia/bitwarden_rs.svg?branch=master)](https://travis-ci.org/dani-garcia/bitwarden_rs) [![Docker Pulls](https://img.shields.io/docker/pulls/vaultwarden/server.svg)](https://hub.docker.com/r/vaultwarden/server)
[![Docker Pulls](https://img.shields.io/docker/pulls/bitwardenrs/server.svg)](https://hub.docker.com/r/bitwardenrs/server) [![Dependency Status](https://deps.rs/repo/github/dani-garcia/vaultwarden/status.svg)](https://deps.rs/repo/github/dani-garcia/vaultwarden)
[![Dependency Status](https://deps.rs/repo/github/dani-garcia/bitwarden_rs/status.svg)](https://deps.rs/repo/github/dani-garcia/bitwarden_rs) [![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/releases/latest)
[![GitHub Release](https://img.shields.io/github/release/dani-garcia/bitwarden_rs.svg)](https://github.com/dani-garcia/bitwarden_rs/releases/latest) [![GPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/blob/master/LICENSE.txt)
[![GPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/bitwarden_rs.svg)](https://github.com/dani-garcia/bitwarden_rs/blob/master/LICENSE.txt) [![Matrix Chat](https://img.shields.io/matrix/vaultwarden:matrix.org.svg?logo=matrix)](https://matrix.to/#/#vaultwarden:matrix.org)
[![Matrix Chat](https://img.shields.io/matrix/bitwarden_rs:matrix.org.svg?logo=matrix)](https://matrix.to/#/#bitwarden_rs:matrix.org)
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/bitwarden_rs). Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/vaultwarden).
**This project is not associated with the [Bitwarden](https://bitwarden.com/) project nor 8bit Solutions LLC.** **This project is not associated with the [Bitwarden](https://bitwarden.com/) project nor 8bit Solutions LLC.**
@@ -33,29 +34,57 @@ Basically full implementation of Bitwarden API is provided including:
Pull the docker image and mount a volume from the host for persistent storage: Pull the docker image and mount a volume from the host for persistent storage:
```sh ```sh
docker pull bitwardenrs/server:latest docker pull vaultwarden/server:latest
docker run -d --name bitwarden -v /bw-data/:/data/ -p 80:80 bitwardenrs/server:latest docker run -d --name vaultwarden -v /vw-data/:/data/ -p 80:80 vaultwarden/server:latest
``` ```
This will preserve any persistent data under /bw-data/, you can adapt the path to whatever suits you. This will preserve any persistent data under /vw-data/, you can adapt the path to whatever suits you.
**IMPORTANT**: Some web browsers, like Chrome, 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 from HTTPS. **IMPORTANT**: Some web browsers, like Chrome, 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 from HTTPS.
This can be configured in [bitwarden_rs directly](https://github.com/dani-garcia/bitwarden_rs/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/bitwarden_rs/wiki/Proxy-examples)). 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). 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).
## Usage ## Usage
See the [bitwarden_rs wiki](https://github.com/dani-garcia/bitwarden_rs/wiki) for more information on how to configure and run the bitwarden_rs server. See the [vaultwarden wiki](https://github.com/dani-garcia/vaultwarden/wiki) for more information on how to configure and run the vaultwarden server.
## Get in touch ## Get in touch
To ask a question, offer suggestions or new features or to get help configuring or installing the software, please [use the forum](https://bitwardenrs.discourse.group/). To ask a question, offer suggestions or new features or to get help configuring or installing the software, please [use the forum](https://vaultwarden.discourse.group/).
If you spot any bugs or crashes with bitwarden_rs itself, please [create an issue](https://github.com/dani-garcia/bitwarden_rs/issues/). Make sure there aren't any similar issues open, though! If you spot any bugs or crashes with vaultwarden itself, please [create an issue](https://github.com/dani-garcia/vaultwarden/issues/). Make sure there aren't any similar issues open, though!
If you prefer to chat, we're usually hanging around at [#bitwarden_rs:matrix.org](https://matrix.to/#/#bitwarden_rs:matrix.org) room on Matrix. Feel free to join us! 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!
### Sponsors ### Sponsors
Thanks for your contribution to the project! Thanks for your contribution to the project!
- [@ChonoN](https://github.com/ChonoN) <table>
- [@themightychris](https://github.com/themightychris) <tr>
<td align="center">
<a href="https://github.com/netdadaltd">
<img src="https://avatars.githubusercontent.com/u/77323954?s=75&v=4" width="75px;" alt="netdadaltd"/>
<br />
<sub><b>netDada Ltd.</b></sub>
</a>
</td>
</tr>
</table>
<br/>
<table>
<tr>
<td align="center">
<a href="https://github.com/Gyarbij" style="width: 75px">
<sub><b>Chono N</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/themightychris">
<sub><b>Chris Alfano</b></sub>
</a>
</td>
</tr>
</table>

45
SECURITY.md Normal file
View File

@@ -0,0 +1,45 @@
Vaultwarden tries to prevent security issues but there could always slip something through.
If you believe you've found a security issue in our application, we encourage you to
notify us. We welcome working with you to resolve the issue promptly. Thanks in advance!
# Disclosure Policy
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every
effort to quickly resolve the issue.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
# In-scope
- Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release.
# Exclusions
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
- 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
- Vulnerabilities in outdated versions of Vaultwarden
- Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue)
- Issues that do not have any impact on the general public
While researching, we'd like to ask you to refrain from:
- Denial of service
- Spamming
- Social engineering (including phishing) of Vaultwarden developers, contributors or users
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 ![security-contact](/.github/security-contact.gif) 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

View File

@@ -1,25 +0,0 @@
pool:
vmImage: 'Ubuntu-16.04'
steps:
- script: |
ls -la
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain $(cat rust-toolchain) --profile=minimal
echo "##vso[task.prependpath]$HOME/.cargo/bin"
displayName: 'Install Rust'
- script: |
sudo apt-get update
sudo apt-get install -y libmysql++-dev
displayName: Install libmysql
- script: |
rustc -Vv
cargo -V
displayName: Query rust and cargo versions
- script : cargo test --features "sqlite"
displayName: 'Test project with sqlite backend'
- script : cargo test --features "mysql"
displayName: 'Test project with mysql backend'

View File

@@ -1,7 +1,7 @@
use std::process::Command;
use std::env; use std::env;
use std::process::Command;
fn main() { fn main() {
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros // This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
println!("cargo:rustc-cfg=sqlite"); println!("cargo:rustc-cfg=sqlite");
@@ -11,8 +11,10 @@ fn main() {
println!("cargo:rustc-cfg=postgresql"); println!("cargo:rustc-cfg=postgresql");
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))] #[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!("You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"); compile_error!(
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
);
if let Ok(version) = env::var("BWRS_VERSION") { if let Ok(version) = env::var("BWRS_VERSION") {
println!("cargo:rustc-env=BWRS_VERSION={}", version); println!("cargo:rustc-env=BWRS_VERSION={}", version);
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version); println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);
@@ -56,12 +58,12 @@ fn read_git_info() -> Result<(), std::io::Error> {
// Combined version // Combined version
let version = if let Some(exact) = exact_tag { let version = if let Some(exact) = exact_tag {
exact exact
} else if &branch != "master" { } else if &branch != "main" && &branch != "master" {
format!("{}-{} ({})", last_tag, rev_short, branch) format!("{}-{} ({})", last_tag, rev_short, branch)
} else { } else {
format!("{}-{}", last_tag, rev_short) format!("{}-{}", last_tag, rev_short)
}; };
println!("cargo:rustc-env=BWRS_VERSION={}", version); println!("cargo:rustc-env=BWRS_VERSION={}", version);
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version); println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);

33
docker/Dockerfile.buildx Normal file
View File

@@ -0,0 +1,33 @@
# The cross-built images have the build arch (`amd64`) embedded in the image
# manifest, rather than the target arch. For example:
#
# $ docker inspect vaultwarden/server:latest-armv7 | jq -r '.[]|.Architecture'
# amd64
#
# Recent versions of Docker have started printing a warning when the image's
# claimed arch doesn't match the host arch. For example:
#
# WARNING: The requested image's platform (linux/amd64) does not match the
# detected host platform (linux/arm/v7) and no specific platform was requested
#
# The image still works fine, but the spurious warning creates confusion.
#
# Docker doesn't seem to provide a way to directly set the arch of an image
# at build time. To resolve the build vs. target arch discrepancy, we use
# Docker Buildx to build a new set of images with the correct target arch.
#
# Docker Buildx uses this Dockerfile to build an image for each requested
# platform. Since the Dockerfile basically consists of a single `FROM`
# instruction, we're effectively telling Buildx to build a platform-specific
# image by simply copying the existing cross-built image and setting the
# correct target arch as a side effect.
#
# References:
#
# - https://docs.docker.com/buildx/working-with-buildx/#build-multi-platform-images
# - https://docs.docker.com/engine/reference/builder/#automatic-platform-args-in-the-global-scope
# - https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact
#
ARG LOCAL_REPO
ARG DOCKER_TAG
FROM ${LOCAL_REPO}:${DOCKER_TAG}-${TARGETARCH}${TARGETVARIANT}

View File

@@ -1,64 +1,89 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
{% set build_stage_base_image = "rust:1.46" %} {% set build_stage_base_image = "rust:1.53" %}
{% if "alpine" in target_file %} {% if "alpine" in target_file %}
{% if "amd64" in target_file %} {% if "amd64" in target_file %}
{% set build_stage_base_image = "clux/muslrust:nightly-2020-10-02" %} {% set build_stage_base_image = "clux/muslrust:nightly-2021-06-24" %}
{% set runtime_stage_base_image = "alpine:3.12" %} {% set runtime_stage_base_image = "alpine:3.14" %}
{% set package_arch_name = "" %} {% set package_arch_target = "x86_64-unknown-linux-musl" %}
{% elif "arm32v7" in target_file %} {% elif "armv7" in target_file %}
{% set build_stage_base_image = "messense/rust-musl-cross:armv7-musleabihf" %} {% set build_stage_base_image = "messense/rust-musl-cross:armv7-musleabihf" %}
{% set runtime_stage_base_image = "balenalib/armv7hf-alpine:3.12" %} {% set runtime_stage_base_image = "balenalib/armv7hf-alpine:3.14" %}
{% set package_arch_name = "" %} {% set package_arch_target = "armv7-unknown-linux-musleabihf" %}
{% endif %} {% endif %}
{% elif "amd64" in target_file %} {% elif "amd64" in target_file %}
{% set runtime_stage_base_image = "debian:buster-slim" %} {% set runtime_stage_base_image = "debian:buster-slim" %}
{% set package_arch_name = "" %} {% elif "arm64" in target_file %}
{% elif "arm64v8" in target_file %}
{% set runtime_stage_base_image = "balenalib/aarch64-debian:buster" %} {% set runtime_stage_base_image = "balenalib/aarch64-debian:buster" %}
{% set package_arch_name = "arm64" %} {% set package_arch_name = "arm64" %}
{% elif "arm32v6" in target_file %} {% set package_arch_target = "aarch64-unknown-linux-gnu" %}
{% set package_cross_compiler = "aarch64-linux-gnu" %}
{% elif "armv6" in target_file %}
{% set runtime_stage_base_image = "balenalib/rpi-debian:buster" %} {% set runtime_stage_base_image = "balenalib/rpi-debian:buster" %}
{% set package_arch_name = "armel" %} {% set package_arch_name = "armel" %}
{% elif "arm32v7" in target_file %} {% set package_arch_target = "arm-unknown-linux-gnueabi" %}
{% set package_cross_compiler = "arm-linux-gnueabi" %}
{% elif "armv7" in target_file %}
{% set runtime_stage_base_image = "balenalib/armv7hf-debian:buster" %} {% set runtime_stage_base_image = "balenalib/armv7hf-debian:buster" %}
{% set package_arch_name = "armhf" %} {% set package_arch_name = "armhf" %}
{% set package_arch_target = "armv7-unknown-linux-gnueabihf" %}
{% set package_cross_compiler = "arm-linux-gnueabihf" %}
{% endif %} {% endif %}
{% set package_arch_prefix = ":" + package_arch_name %} {% if package_arch_name is defined %}
{% if package_arch_name == "" %} {% set package_arch_prefix = ":" + package_arch_name %}
{% else %}
{% set package_arch_prefix = "" %} {% set package_arch_prefix = "" %}
{% endif %} {% endif %}
{% if package_arch_target is defined %}
{% set package_arch_target_param = " --target=" + package_arch_target %}
{% else %}
{% set package_arch_target_param = "" %}
{% endif %}
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
{% set vault_image_hash = "sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303" %} {% set vault_version = "2.20.4b" %}
{% raw %} {% set vault_image_digest = "sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05" %}
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # The web-vault digest specifies a particular web-vault build on Docker Hub.
# It can be viewed in multiple ways: # Using the digest instead of the tag name provides better security,
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # as the digest of an image is immutable, whereas a tag name can later
# - From the console, with the following commands: # be changed to point to a malicious image.
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
{% endraw %} # click the tag name to view the digest of the image it currently points to.
FROM bitwardenrs/web-vault@{{ vault_image_hash }} as vault # - From the command line:
# $ docker pull vaultwarden/web-vault:v{{ vault_version }}
# $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" vaultwarden/web-vault:v{{ vault_version }}
# [vaultwarden/web-vault@{{ vault_image_digest }}]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" vaultwarden/web-vault@{{ vault_image_digest }}
# [vaultwarden/web-vault:v{{ vault_version }}]
#
FROM vaultwarden/web-vault@{{ vault_image_digest }} as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM {{ build_stage_base_image }} as build FROM {{ build_stage_base_image }} as build
{% if "alpine" in target_file %} {% if "alpine" in target_file %}
# Alpine only works on SQlite {% if "amd64" in target_file %}
# Alpine-based AMD64 (musl) does not support mysql/mariadb during compile time.
ARG DB=sqlite,postgresql
{% set features = "sqlite,postgresql" %}
{% else %}
# Alpine-based ARM (musl) only supports sqlite during compile time.
ARG DB=sqlite ARG DB=sqlite
{% set features = "sqlite" %}
{% endif %}
{% else %} {% else %}
# Debian-based builds support multidb # Debian-based builds support multidb
ARG DB=sqlite,mysql,postgresql ARG DB=sqlite,mysql,postgresql
{% set features = "sqlite,mysql,postgresql" %}
{% endif %} {% endif %}
# Build time options to avoid dpkg warnings and help with reproducible builds. # Build time options to avoid dpkg warnings and help with reproducible builds.
ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
@@ -68,7 +93,9 @@ RUN rustup set profile minimal
{% if "alpine" in target_file %} {% if "alpine" in target_file %}
ENV USER "root" ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s' ENV RUSTFLAGS='-C link-arg=-s'
{% if "armv7" in target_file %}
ENV CFLAGS_armv7_unknown_linux_musleabihf="-mfpu=vfpv3-d16"
{% endif %}
{% elif "arm" in target_file %} {% elif "arm" in target_file %}
# Install required build libs for {{ package_arch_name }} architecture. # Install required build libs for {{ package_arch_name }} architecture.
# To compile both mysql and postgresql we need some extra packages for both host arch and target arch # To compile both mysql and postgresql we need some extra packages for both host arch and target arch
@@ -83,46 +110,17 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
libpq5{{ package_arch_prefix }} \ libpq5{{ package_arch_prefix }} \
libpq-dev \ libpq-dev \
libmariadb-dev{{ package_arch_prefix }} \ libmariadb-dev{{ package_arch_prefix }} \
libmariadb-dev-compat{{ package_arch_prefix }} libmariadb-dev-compat{{ package_arch_prefix }} \
gcc-{{ package_cross_compiler }} \
&& mkdir -p ~/.cargo \
&& echo '[target.{{ package_arch_target }}]' >> ~/.cargo/config \
&& echo 'linker = "{{ package_cross_compiler }}-gcc"' >> ~/.cargo/config \
&& echo 'rustflags = ["-L/usr/lib/{{ package_cross_compiler }}"]' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% endif -%} {% endif -%}
{% if "arm64v8" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-aarch64-linux-gnu \
&& mkdir -p ~/.cargo \
&& echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config \
&& echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config \
&& echo 'rustflags = ["-L/usr/lib/aarch64-linux-gnu"]' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% elif "arm32v6" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-arm-linux-gnueabi \
&& mkdir -p ~/.cargo \
&& echo '[target.arm-unknown-linux-gnueabi]' >> ~/.cargo/config \
&& echo 'linker = "arm-linux-gnueabi-gcc"' >> ~/.cargo/config \
&& echo 'rustflags = ["-L/usr/lib/arm-linux-gnueabi"]' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% elif "arm32v7" in target_file and "alpine" not in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-arm-linux-gnueabihf \
&& mkdir -p ~/.cargo \
&& echo '[target.armv7-unknown-linux-gnueabihf]' >> ~/.cargo/config \
&& echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config \
&& echo 'rustflags = ["-L/usr/lib/arm-linux-gnueabihf"]' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% endif %}
{% if "amd64" in target_file and "alpine" not in target_file %} {% if "amd64" in target_file and "alpine" not in target_file %}
# Install DB packages # Install DB packages
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -148,72 +146,31 @@ COPY ./build.rs ./build.rs
# We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client) # We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client)
# We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the {{ package_arch_prefix }} version. # We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the {{ package_arch_prefix }} version.
# What we can do is a force install, because nothing important is overlapping each other. # What we can do is a force install, because nothing important is overlapping each other.
RUN apt-get install -y libmariadb3:amd64 && \ RUN apt-get install -y --no-install-recommends libmariadb3:amd64 \
mkdir -pv /tmp/dpkg && \ && apt-get download libmariadb-dev-compat:amd64 \
cd /tmp/dpkg && \ && dpkg --force-all -i ./libmariadb-dev-compat*.deb \
apt-get download libmariadb-dev-compat:amd64 && \ && rm -rvf ./libmariadb-dev-compat*.deb \
dpkg --force-all -i *.deb && \ # For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic.
rm -rf /tmp/dpkg # The libpq5{{ package_arch_prefix }} package seems to not provide a symlink to libpq.so.5 with the name libpq.so.
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# Without this specific file the ld command will fail and compilation fails with it.
&& ln -sfnr /usr/lib/{{ package_cross_compiler }}/libpq.so.5 /usr/lib/{{ package_cross_compiler }}/libpq.so
# For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic. ENV CC_{{ package_arch_target | replace("-", "_") }}="/usr/bin/{{ package_cross_compiler }}-gcc"
# The libpq5{{ package_arch_prefix }} package seems to not provide a symlink to libpq.so.5 with the name libpq.so. ENV CROSS_COMPILE="1"
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time. ENV OPENSSL_INCLUDE_DIR="/usr/include/{{ package_cross_compiler }}"
# Without this specific file the ld command will fail and compilation fails with it. ENV OPENSSL_LIB_DIR="/usr/lib/{{ package_cross_compiler }}"
{% endif -%} {% endif -%}
{% if "arm64v8" in target_file %} {% endif %}
RUN ln -sfnr /usr/lib/aarch64-linux-gnu/libpq.so.5 /usr/lib/aarch64-linux-gnu/libpq.so {% if package_arch_target is defined %}
RUN rustup target add {{ package_arch_target }}
ENV CC_aarch64_unknown_linux_gnu="/usr/bin/aarch64-linux-gnu-gcc"
ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/aarch64-linux-gnu"
ENV OPENSSL_LIB_DIR="/usr/lib/aarch64-linux-gnu"
RUN rustup target add aarch64-unknown-linux-gnu
{% elif "arm32v6" in target_file %}
RUN ln -sfnr /usr/lib/arm-linux-gnueabi/libpq.so.5 /usr/lib/arm-linux-gnueabi/libpq.so
ENV CC_arm_unknown_linux_gnueabi="/usr/bin/arm-linux-gnueabi-gcc"
ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabi"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabi"
RUN rustup target add arm-unknown-linux-gnueabi
{% elif "arm32v7" in target_file %}
RUN ln -sfnr /usr/lib/arm-linux-gnueabihf/libpq.so.5 /usr/lib/arm-linux-gnueabihf/libpq.so
ENV CC_armv7_unknown_linux_gnueabihf="/usr/bin/arm-linux-gnueabihf-gcc"
ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabihf"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
RUN rustup target add armv7-unknown-linux-gnueabihf
{% endif -%}
{% else -%}
{% if "amd64" in target_file %}
RUN rustup target add x86_64-unknown-linux-musl
{% elif "arm32v7" in target_file %}
RUN rustup target add armv7-unknown-linux-musleabihf
{% endif %}
{% endif %} {% endif %}
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
{% if "alpine" in target_file %} RUN cargo build --features ${DB} --release{{ package_arch_target_param }} \
{% if "amd64" in target_file %} && find . -not -path "./target*" -delete
RUN cargo build --features ${DB} --release --target=x86_64-unknown-linux-musl
{% elif "arm32v7" in target_file %}
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf
{% endif %}
{% elif "alpine" not in target_file %}
{% if "amd64" in target_file %}
RUN cargo build --features ${DB} --release
{% elif "arm64v8" in target_file %}
RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu
{% elif "arm32v6" in target_file %}
RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi
{% elif "arm32v7" in target_file %}
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf
{% endif %}
{% endif %}
RUN find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -224,22 +181,11 @@ RUN touch src/main.rs
# Builds again, this time it'll just be # Builds again, this time it'll just be
# your actual source files being built # your actual source files being built
RUN cargo build --features ${DB} --release{{ package_arch_target_param }}
{% if "alpine" in target_file %} {% if "alpine" in target_file %}
{% if "amd64" in target_file %} {% if "armv7" in target_file %}
RUN cargo build --features ${DB} --release --target=x86_64-unknown-linux-musl # hadolint ignore=DL3059
{% elif "arm32v7" in target_file %} RUN musl-strip target/{{ package_arch_target }}/release/vaultwarden
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf
RUN musl-strip target/armv7-unknown-linux-musleabihf/release/bitwarden_rs
{% endif %}
{% elif "alpine" not in target_file %}
{% if "amd64" in target_file %}
RUN cargo build --features ${DB} --release
{% elif "arm64v8" in target_file %}
RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu
{% elif "arm32v6" in target_file %}
RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi
{% elif "arm32v7" in target_file %}
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -256,67 +202,54 @@ ENV SSL_CERT_DIR=/etc/ssl/certs
{% endif %} {% endif %}
{% if "amd64" not in target_file %} {% if "amd64" not in target_file %}
# hadolint ignore=DL3059
RUN [ "cross-build-start" ] RUN [ "cross-build-start" ]
{% endif %} {% endif %}
# Install needed libraries
# Create data folder and Install needed libraries
RUN mkdir /data \
{% if "alpine" in runtime_stage_base_image %} {% if "alpine" in runtime_stage_base_image %}
RUN apk add --no-cache \ && apk add --no-cache \
openssl \ openssl \
curl \ curl \
{% if "sqlite" in target_file %} dumb-init \
sqlite \ {% if "mysql" in features %}
{% elif "mysql" in target_file %}
mariadb-connector-c \ mariadb-connector-c \
{% elif "postgresql" in target_file %} {% endif %}
{% if "postgresql" in features %}
postgresql-libs \ postgresql-libs \
{% endif %} {% endif %}
ca-certificates ca-certificates
{% else %} {% else %}
RUN apt-get update && apt-get install -y \ && apt-get update && apt-get install -y \
--no-install-recommends \ --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
curl \ curl \
sqlite3 \ dumb-init \
libmariadb-dev-compat \ libmariadb-dev-compat \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
{% endif %} {% endif %}
{% if "alpine" in target_file and "arm32v7" in target_file %}
RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community catatonit
{% endif %}
RUN mkdir /data
{% if "amd64" not in target_file %} {% if "amd64" not in target_file %}
# hadolint ignore=DL3059
RUN [ "cross-build-end" ] RUN [ "cross-build-end" ]
{% endif %} {% endif %}
VOLUME /data VOLUME /data
EXPOSE 80 EXPOSE 80
EXPOSE 3012 EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
{% if "alpine" in target_file %} {% if package_arch_target is defined %}
{% if "amd64" in target_file %} COPY --from=build /app/target/{{ package_arch_target }}/release/vaultwarden .
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs . {% else %}
{% elif "arm32v7" in target_file %} COPY --from=build /app/target/release/vaultwarden .
COPY --from=build /app/target/armv7-unknown-linux-musleabihf/release/bitwarden_rs .
{% endif %}
{% elif "alpine" not in target_file %}
{% if "arm64v8" in target_file %}
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
{% elif "arm32v6" in target_file %}
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
{% elif "arm32v7" in target_file %}
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
{% else %}
COPY --from=build app/target/release/bitwarden_rs .
{% endif %}
{% endif %} {% endif %}
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
@@ -325,9 +258,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
{% if "alpine" in target_file and "arm32v7" in target_file %}
CMD ["catatonit", "/start.sh"]
{% else %}
CMD ["/start.sh"] CMD ["/start.sh"]
{% endif %}

View File

@@ -1,4 +1,4 @@
OBJECTS := $(shell find -mindepth 2 -name 'Dockerfile*') OBJECTS := $(shell find ./ -mindepth 2 -name 'Dockerfile*')
all: $(OBJECTS) all: $(OBJECTS)

View File

@@ -1,24 +1,31 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM rust:1.46 as build FROM rust:1.53 as build
# Debian-based builds support multidb # Debian-based builds support multidb
ARG DB=sqlite,mysql,postgresql ARG DB=sqlite,mysql,postgresql
@@ -49,8 +56,8 @@ COPY ./build.rs ./build.rs
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release RUN cargo build --features ${DB} --release \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -72,27 +79,30 @@ ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
# Install needed libraries
RUN apt-get update && apt-get install -y \ # Create data folder and Install needed libraries
RUN mkdir /data \
&& apt-get update && apt-get install -y \
--no-install-recommends \ --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
curl \ curl \
sqlite3 \ dumb-init \
libmariadb-dev-compat \ libmariadb-dev-compat \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir /data
VOLUME /data VOLUME /data
EXPOSE 80 EXPOSE 80
EXPOSE 3012 EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build app/target/release/bitwarden_rs . COPY --from=build /app/target/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -100,6 +110,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/start.sh"] CMD ["/start.sh"]

View File

@@ -1,27 +1,34 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM clux/muslrust:nightly-2020-10-02 as build FROM clux/muslrust:nightly-2021-06-24 as build
# Alpine only works on SQlite # Alpine-based AMD64 (musl) does not support mysql/mariadb during compile time.
ARG DB=sqlite ARG DB=sqlite,postgresql
# Build time options to avoid dpkg warnings and help with reproducible builds. # Build time options to avoid dpkg warnings and help with reproducible builds.
ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
@@ -32,7 +39,6 @@ RUN rustup set profile minimal
ENV USER "root" ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s' ENV RUSTFLAGS='-C link-arg=-s'
# Creates a dummy project used to grab dependencies # Creates a dummy project used to grab dependencies
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
@@ -47,8 +53,8 @@ RUN rustup target add x86_64-unknown-linux-musl
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release --target=x86_64-unknown-linux-musl RUN cargo build --features ${DB} --release --target=x86_64-unknown-linux-musl \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -64,29 +70,34 @@ RUN cargo build --features ${DB} --release --target=x86_64-unknown-linux-musl
######################## RUNTIME IMAGE ######################## ######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image # Create a new stage with a minimal image
# because we already have a binary built # because we already have a binary built
FROM alpine:3.12 FROM alpine:3.14
ENV ROCKET_ENV "staging" ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
ENV SSL_CERT_DIR=/etc/ssl/certs ENV SSL_CERT_DIR=/etc/ssl/certs
# Install needed libraries
RUN apk add --no-cache \ # Create data folder and Install needed libraries
RUN mkdir /data \
&& apk add --no-cache \
openssl \ openssl \
curl \ curl \
dumb-init \
postgresql-libs \
ca-certificates ca-certificates
RUN mkdir /data
VOLUME /data VOLUME /data
EXPOSE 80 EXPOSE 80
EXPOSE 3012 EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs . COPY --from=build /app/target/x86_64-unknown-linux-musl/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -94,6 +105,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/start.sh"] CMD ["/start.sh"]

View File

@@ -1,24 +1,31 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM rust:1.46 as build FROM rust:1.53 as build
# Debian-based builds support multidb # Debian-based builds support multidb
ARG DB=sqlite,mysql,postgresql ARG DB=sqlite,mysql,postgresql
@@ -42,11 +49,7 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
libpq5:arm64 \ libpq5:arm64 \
libpq-dev \ libpq-dev \
libmariadb-dev:arm64 \ libmariadb-dev:arm64 \
libmariadb-dev-compat:arm64 libmariadb-dev-compat:arm64 \
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
&& mkdir -p ~/.cargo \ && mkdir -p ~/.cargo \
&& echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config \ && echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config \
@@ -70,18 +73,15 @@ COPY ./build.rs ./build.rs
# We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client) # We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client)
# We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :arm64 version. # We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :arm64 version.
# What we can do is a force install, because nothing important is overlapping each other. # What we can do is a force install, because nothing important is overlapping each other.
RUN apt-get install -y libmariadb3:amd64 && \ RUN apt-get install -y --no-install-recommends libmariadb3:amd64 \
mkdir -pv /tmp/dpkg && \ && apt-get download libmariadb-dev-compat:amd64 \
cd /tmp/dpkg && \ && dpkg --force-all -i ./libmariadb-dev-compat*.deb \
apt-get download libmariadb-dev-compat:amd64 && \ && rm -rvf ./libmariadb-dev-compat*.deb \
dpkg --force-all -i *.deb && \ # For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic.
rm -rf /tmp/dpkg # The libpq5:arm64 package seems to not provide a symlink to libpq.so.5 with the name libpq.so.
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic. # Without this specific file the ld command will fail and compilation fails with it.
# The libpq5:arm64 package seems to not provide a symlink to libpq.so.5 with the name libpq.so. && ln -sfnr /usr/lib/aarch64-linux-gnu/libpq.so.5 /usr/lib/aarch64-linux-gnu/libpq.so
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# Without this specific file the ld command will fail and compilation fails with it.
RUN ln -sfnr /usr/lib/aarch64-linux-gnu/libpq.so.5 /usr/lib/aarch64-linux-gnu/libpq.so
ENV CC_aarch64_unknown_linux_gnu="/usr/bin/aarch64-linux-gnu-gcc" ENV CC_aarch64_unknown_linux_gnu="/usr/bin/aarch64-linux-gnu-gcc"
ENV CROSS_COMPILE="1" ENV CROSS_COMPILE="1"
@@ -92,8 +92,8 @@ RUN rustup target add aarch64-unknown-linux-gnu
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -115,21 +115,22 @@ ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
# hadolint ignore=DL3059
RUN [ "cross-build-start" ] RUN [ "cross-build-start" ]
# Install needed libraries # Create data folder and Install needed libraries
RUN apt-get update && apt-get install -y \ RUN mkdir /data \
&& apt-get update && apt-get install -y \
--no-install-recommends \ --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
curl \ curl \
sqlite3 \ dumb-init \
libmariadb-dev-compat \ libmariadb-dev-compat \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir /data # hadolint ignore=DL3059
RUN [ "cross-build-end" ] RUN [ "cross-build-end" ]
VOLUME /data VOLUME /data
@@ -138,9 +139,10 @@ EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs . COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -148,6 +150,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/start.sh"] CMD ["/start.sh"]

View File

@@ -1,24 +1,31 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM rust:1.46 as build FROM rust:1.53 as build
# Debian-based builds support multidb # Debian-based builds support multidb
ARG DB=sqlite,mysql,postgresql ARG DB=sqlite,mysql,postgresql
@@ -42,11 +49,7 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
libpq5:armel \ libpq5:armel \
libpq-dev \ libpq-dev \
libmariadb-dev:armel \ libmariadb-dev:armel \
libmariadb-dev-compat:armel libmariadb-dev-compat:armel \
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-arm-linux-gnueabi \ gcc-arm-linux-gnueabi \
&& mkdir -p ~/.cargo \ && mkdir -p ~/.cargo \
&& echo '[target.arm-unknown-linux-gnueabi]' >> ~/.cargo/config \ && echo '[target.arm-unknown-linux-gnueabi]' >> ~/.cargo/config \
@@ -70,18 +73,15 @@ COPY ./build.rs ./build.rs
# We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client) # We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client)
# We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :armel version. # We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :armel version.
# What we can do is a force install, because nothing important is overlapping each other. # What we can do is a force install, because nothing important is overlapping each other.
RUN apt-get install -y libmariadb3:amd64 && \ RUN apt-get install -y --no-install-recommends libmariadb3:amd64 \
mkdir -pv /tmp/dpkg && \ && apt-get download libmariadb-dev-compat:amd64 \
cd /tmp/dpkg && \ && dpkg --force-all -i ./libmariadb-dev-compat*.deb \
apt-get download libmariadb-dev-compat:amd64 && \ && rm -rvf ./libmariadb-dev-compat*.deb \
dpkg --force-all -i *.deb && \ # For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic.
rm -rf /tmp/dpkg # The libpq5:armel package seems to not provide a symlink to libpq.so.5 with the name libpq.so.
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic. # Without this specific file the ld command will fail and compilation fails with it.
# The libpq5:armel package seems to not provide a symlink to libpq.so.5 with the name libpq.so. && ln -sfnr /usr/lib/arm-linux-gnueabi/libpq.so.5 /usr/lib/arm-linux-gnueabi/libpq.so
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# Without this specific file the ld command will fail and compilation fails with it.
RUN ln -sfnr /usr/lib/arm-linux-gnueabi/libpq.so.5 /usr/lib/arm-linux-gnueabi/libpq.so
ENV CC_arm_unknown_linux_gnueabi="/usr/bin/arm-linux-gnueabi-gcc" ENV CC_arm_unknown_linux_gnueabi="/usr/bin/arm-linux-gnueabi-gcc"
ENV CROSS_COMPILE="1" ENV CROSS_COMPILE="1"
@@ -92,8 +92,8 @@ RUN rustup target add arm-unknown-linux-gnueabi
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -115,21 +115,22 @@ ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
# hadolint ignore=DL3059
RUN [ "cross-build-start" ] RUN [ "cross-build-start" ]
# Install needed libraries # Create data folder and Install needed libraries
RUN apt-get update && apt-get install -y \ RUN mkdir /data \
&& apt-get update && apt-get install -y \
--no-install-recommends \ --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
curl \ curl \
sqlite3 \ dumb-init \
libmariadb-dev-compat \ libmariadb-dev-compat \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir /data # hadolint ignore=DL3059
RUN [ "cross-build-end" ] RUN [ "cross-build-end" ]
VOLUME /data VOLUME /data
@@ -138,9 +139,10 @@ EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs . COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -148,6 +150,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/start.sh"] CMD ["/start.sh"]

View File

@@ -1,24 +1,31 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM rust:1.46 as build FROM rust:1.53 as build
# Debian-based builds support multidb # Debian-based builds support multidb
ARG DB=sqlite,mysql,postgresql ARG DB=sqlite,mysql,postgresql
@@ -42,11 +49,7 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
libpq5:armhf \ libpq5:armhf \
libpq-dev \ libpq-dev \
libmariadb-dev:armhf \ libmariadb-dev:armhf \
libmariadb-dev-compat:armhf libmariadb-dev-compat:armhf \
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-arm-linux-gnueabihf \ gcc-arm-linux-gnueabihf \
&& mkdir -p ~/.cargo \ && mkdir -p ~/.cargo \
&& echo '[target.armv7-unknown-linux-gnueabihf]' >> ~/.cargo/config \ && echo '[target.armv7-unknown-linux-gnueabihf]' >> ~/.cargo/config \
@@ -70,18 +73,15 @@ COPY ./build.rs ./build.rs
# We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client) # We at least need libmariadb3:amd64 installed for the x86_64 version of libmariadb.so (client)
# We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :armhf version. # We also need the libmariadb-dev-compat:amd64 but it can not be installed together with the :armhf version.
# What we can do is a force install, because nothing important is overlapping each other. # What we can do is a force install, because nothing important is overlapping each other.
RUN apt-get install -y libmariadb3:amd64 && \ RUN apt-get install -y --no-install-recommends libmariadb3:amd64 \
mkdir -pv /tmp/dpkg && \ && apt-get download libmariadb-dev-compat:amd64 \
cd /tmp/dpkg && \ && dpkg --force-all -i ./libmariadb-dev-compat*.deb \
apt-get download libmariadb-dev-compat:amd64 && \ && rm -rvf ./libmariadb-dev-compat*.deb \
dpkg --force-all -i *.deb && \ # For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic.
rm -rf /tmp/dpkg # The libpq5:armhf package seems to not provide a symlink to libpq.so.5 with the name libpq.so.
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# For Diesel-RS migrations_macros to compile with PostgreSQL we need to do some magic. # Without this specific file the ld command will fail and compilation fails with it.
# The libpq5:armhf package seems to not provide a symlink to libpq.so.5 with the name libpq.so. && ln -sfnr /usr/lib/arm-linux-gnueabihf/libpq.so.5 /usr/lib/arm-linux-gnueabihf/libpq.so
# This is only provided by the libpq-dev package which can't be installed for both arch at the same time.
# Without this specific file the ld command will fail and compilation fails with it.
RUN ln -sfnr /usr/lib/arm-linux-gnueabihf/libpq.so.5 /usr/lib/arm-linux-gnueabihf/libpq.so
ENV CC_armv7_unknown_linux_gnueabihf="/usr/bin/arm-linux-gnueabihf-gcc" ENV CC_armv7_unknown_linux_gnueabihf="/usr/bin/arm-linux-gnueabihf-gcc"
ENV CROSS_COMPILE="1" ENV CROSS_COMPILE="1"
@@ -92,8 +92,8 @@ RUN rustup target add armv7-unknown-linux-gnueabihf
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -115,21 +115,22 @@ ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
# hadolint ignore=DL3059
RUN [ "cross-build-start" ] RUN [ "cross-build-start" ]
# Install needed libraries # Create data folder and Install needed libraries
RUN apt-get update && apt-get install -y \ RUN mkdir /data \
&& apt-get update && apt-get install -y \
--no-install-recommends \ --no-install-recommends \
openssl \ openssl \
ca-certificates \ ca-certificates \
curl \ curl \
sqlite3 \ dumb-init \
libmariadb-dev-compat \ libmariadb-dev-compat \
libpq5 \ libpq5 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN mkdir /data # hadolint ignore=DL3059
RUN [ "cross-build-end" ] RUN [ "cross-build-end" ]
VOLUME /data VOLUME /data
@@ -138,9 +139,10 @@ EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs . COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -148,6 +150,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/start.sh"] CMD ["/start.sh"]

View File

@@ -1,26 +1,33 @@
# This file was generated using a Jinja2 template. # This file was generated using a Jinja2 template.
# Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfile's. # Please make your changes in `Dockerfile.j2` and then `make` the individual Dockerfiles.
# Using multistage build: # Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/ # https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/ # https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
# The web-vault digest specifies a particular web-vault build on Docker Hub.
# This hash is extracted from the docker web-vault builds and it's preferred over a simple tag because it's immutable. # Using the digest instead of the tag name provides better security,
# It can be viewed in multiple ways: # as the digest of an image is immutable, whereas a tag name can later
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there. # be changed to point to a malicious image.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.16.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.16.1
# #
# - To do the opposite, and get the tag from the hash, you can do: # To verify the current digest for a given tag name:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
FROM bitwardenrs/web-vault@sha256:e40228f94cead5e50af6575fb39850a002dad146dab6836e5da5663e6d214303 as vault # click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull vaultwarden/web-vault:v2.20.4b
# $ docker image inspect --format "{{.RepoDigests}}" vaultwarden/web-vault:v2.20.4b
# [vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05
# [vaultwarden/web-vault:v2.20.4b]
#
FROM vaultwarden/web-vault@sha256:894e266d4491494dd5a8a736855a6772aa146fa14206853b11b41cf3f3f64d05 as vault
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
FROM messense/rust-musl-cross:armv7-musleabihf as build FROM messense/rust-musl-cross:armv7-musleabihf as build
# Alpine only works on SQlite # Alpine-based ARM (musl) only supports sqlite during compile time.
ARG DB=sqlite ARG DB=sqlite
# Build time options to avoid dpkg warnings and help with reproducible builds. # Build time options to avoid dpkg warnings and help with reproducible builds.
@@ -31,7 +38,7 @@ RUN rustup set profile minimal
ENV USER "root" ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s' ENV RUSTFLAGS='-C link-arg=-s'
ENV CFLAGS_armv7_unknown_linux_musleabihf="-mfpu=vfpv3-d16"
# Creates a dummy project used to grab dependencies # Creates a dummy project used to grab dependencies
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
@@ -47,8 +54,8 @@ RUN rustup target add armv7-unknown-linux-musleabihf
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf \
RUN find . -not -path "./target*" -delete && find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
# To avoid copying unneeded files, use .dockerignore # To avoid copying unneeded files, use .dockerignore
@@ -60,29 +67,31 @@ RUN touch src/main.rs
# Builds again, this time it'll just be # Builds again, this time it'll just be
# your actual source files being built # your actual source files being built
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf
RUN musl-strip target/armv7-unknown-linux-musleabihf/release/bitwarden_rs # hadolint ignore=DL3059
RUN musl-strip target/armv7-unknown-linux-musleabihf/release/vaultwarden
######################## RUNTIME IMAGE ######################## ######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image # Create a new stage with a minimal image
# because we already have a binary built # because we already have a binary built
FROM balenalib/armv7hf-alpine:3.12 FROM balenalib/armv7hf-alpine:3.14
ENV ROCKET_ENV "staging" ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80 ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10 ENV ROCKET_WORKERS=10
ENV SSL_CERT_DIR=/etc/ssl/certs ENV SSL_CERT_DIR=/etc/ssl/certs
# hadolint ignore=DL3059
RUN [ "cross-build-start" ] RUN [ "cross-build-start" ]
# Install needed libraries # Create data folder and Install needed libraries
RUN apk add --no-cache \ RUN mkdir /data \
&& apk add --no-cache \
openssl \ openssl \
curl \ curl \
dumb-init \
ca-certificates ca-certificates
RUN apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community catatonit
RUN mkdir /data
# hadolint ignore=DL3059
RUN [ "cross-build-end" ] RUN [ "cross-build-end" ]
VOLUME /data VOLUME /data
@@ -91,9 +100,10 @@ EXPOSE 3012
# Copies the files from the context (Rocket.toml file and web-vault) # Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage # and the binary from the "build" stage to the current stage
WORKDIR /
COPY Rocket.toml . COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/armv7-unknown-linux-musleabihf/release/bitwarden_rs . COPY --from=build /app/target/armv7-unknown-linux-musleabihf/release/vaultwarden .
COPY docker/healthcheck.sh /healthcheck.sh COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh COPY docker/start.sh /start.sh
@@ -101,6 +111,5 @@ COPY docker/start.sh /start.sh
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup! # Configures the startup!
WORKDIR / ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["catatonit", "/start.sh"] CMD ["/start.sh"]

View File

@@ -1,10 +1,20 @@
#!/bin/sh #!/bin/sh
if [ -r /etc/bitwarden_rs.sh ]; then if [ -r /etc/vaultwarden.sh ]; then
. /etc/vaultwarden.sh
elif [ -r /etc/bitwarden_rs.sh ]; then
echo "### You are using the old /etc/bitwarden_rs.sh script, please migrate to /etc/vaultwarden.sh ###"
. /etc/bitwarden_rs.sh . /etc/bitwarden_rs.sh
fi fi
if [ -d /etc/bitwarden_rs.d ]; then if [ -d /etc/vaultwarden.d ]; then
for f in /etc/vaultwarden.d/*.sh; do
if [ -r $f ]; then
. $f
fi
done
elif [ -d /etc/bitwarden_rs.d ]; then
echo "### You are using the old /etc/bitwarden_rs.d script directory, please migrate to /etc/vaultwarden.d ###"
for f in /etc/bitwarden_rs.d/*.sh; do for f in /etc/bitwarden_rs.d/*.sh; do
if [ -r $f ]; then if [ -r $f ]; then
. $f . $f
@@ -12,4 +22,4 @@ if [ -d /etc/bitwarden_rs.d ]; then
done done
fi fi
exec /bitwarden_rs "${@}" exec /vaultwarden "${@}"

View File

@@ -10,7 +10,7 @@ Docker Hub hooks provide these predefined [environment variables](https://docs.d
* `DOCKER_TAG`: the Docker repository tag being built. * `DOCKER_TAG`: the Docker repository tag being built.
* `IMAGE_NAME`: the name and tag of the Docker repository being built. (This variable is a combination of `DOCKER_REPO:DOCKER_TAG`.) * `IMAGE_NAME`: the name and tag of the Docker repository being built. (This variable is a combination of `DOCKER_REPO:DOCKER_TAG`.)
The current multi-arch image build relies on the original bitwarden_rs Dockerfiles, which use cross-compilation for architectures other than `amd64`, and don't yet support all arch/database/OS combinations. However, cross-compilation is much faster than QEMU-based builds (e.g., using `docker buildx`). This situation may need to be revisited at some point. The current multi-arch image build relies on the original vaultwarden Dockerfiles, which use cross-compilation for architectures other than `amd64`, and don't yet support all arch/distro combinations. However, cross-compilation is much faster than QEMU-based builds (e.g., using `docker buildx`). This situation may need to be revisited at some point.
## References ## References

View File

@@ -1,19 +1,16 @@
# The default Debian-based images support these arches for all database connections # The default Debian-based images support these arches for all database backends.
#
# Other images (Alpine-based) currently
# support only a subset of these.
arches=( arches=(
amd64 amd64
arm32v6 armv6
arm32v7 armv7
arm64v8 arm64
) )
if [[ "${DOCKER_TAG}" == *alpine ]]; then if [[ "${DOCKER_TAG}" == *alpine ]]; then
# The Alpine build currently only works for amd64. # The Alpine image build currently only works for certain arches.
os_suffix=.alpine distro_suffix=.alpine
arches=( arches=(
amd64 amd64
arm32v7 armv7
) )
fi fi

View File

@@ -4,11 +4,42 @@ echo ">>> Building images..."
source ./hooks/arches.sh source ./hooks/arches.sh
if [[ -z "${SOURCE_COMMIT}" ]]; then
# This var is typically predefined by Docker Hub, but it won't be
# when testing locally.
SOURCE_COMMIT="$(git rev-parse HEAD)"
fi
# Construct a version string in the style of `build.rs`.
GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null)"
if [[ -n "${GIT_EXACT_TAG}" ]]; then
SOURCE_VERSION="${GIT_EXACT_TAG}"
else
GIT_LAST_TAG="$(git describe --tags --abbrev=0)"
SOURCE_VERSION="${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}"
fi
LABELS=(
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
org.opencontainers.image.created="$(date --utc --iso-8601=seconds)"
org.opencontainers.image.documentation="https://github.com/dani-garcia/vaultwarden/wiki"
org.opencontainers.image.licenses="GPL-3.0-only"
org.opencontainers.image.revision="${SOURCE_COMMIT}"
org.opencontainers.image.source="${SOURCE_REPOSITORY_URL}"
org.opencontainers.image.url="https://hub.docker.com/r/${DOCKER_REPO#*/}"
org.opencontainers.image.version="${SOURCE_VERSION}"
)
LABEL_ARGS=()
for label in "${LABELS[@]}"; do
LABEL_ARGS+=(--label "${label}")
done
set -ex set -ex
for arch in "${arches[@]}"; do for arch in "${arches[@]}"; do
docker build \ docker build \
"${LABEL_ARGS[@]}" \
-t "${DOCKER_REPO}:${DOCKER_TAG}-${arch}" \ -t "${DOCKER_REPO}:${DOCKER_TAG}-${arch}" \
-f docker/${arch}/Dockerfile${os_suffix} \ -f docker/${arch}/Dockerfile${distro_suffix} \
. .
done done

28
hooks/pre_build Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -ex
# If requested, print some environment info for troubleshooting.
if [[ -n "${DOCKER_HUB_DEBUG}" ]]; then
id
pwd
df -h
env
docker info
docker version
fi
# Install build dependencies.
deps=(
jq
)
apt-get update
apt-get install -y "${deps[@]}"
# Docker Hub uses a shallow clone and doesn't fetch tags, which breaks some
# Git operations that we perform later, so fetch the complete history and
# tags first. Note that if the build is cached, the clone may have been
# unshallowed already; if so, unshallowing will fail, so skip it.
if [[ -f .git/shallow ]]; then
git fetch --unshallow --tags
fi

View File

@@ -1,117 +1,138 @@
#!/bin/bash #!/bin/bash
echo ">>> Pushing images..."
export DOCKER_CLI_EXPERIMENTAL=enabled
declare -A annotations=(
[amd64]="--os linux --arch amd64"
[arm32v6]="--os linux --arch arm --variant v6"
[arm32v7]="--os linux --arch arm --variant v7"
[arm64v8]="--os linux --arch arm64 --variant v8"
)
source ./hooks/arches.sh source ./hooks/arches.sh
export DOCKER_CLI_EXPERIMENTAL=enabled
# Join a list of args with a single char.
# Ref: https://stackoverflow.com/a/17841619
join() { local IFS="$1"; shift; echo "$*"; }
set -ex set -ex
declare -A images echo ">>> Starting local Docker registry..."
# Docker Buildx's `docker-container` driver is needed for multi-platform
# builds, but it can't access existing images on the Docker host (like the
# cross-compiled ones we just built). Those images first need to be pushed to
# a registry -- Docker Hub could be used, but since it's not trivial to clean
# up those intermediate images on Docker Hub, it's easier to just run a local
# Docker registry, which gets cleaned up automatically once the build job ends.
#
# https://docs.docker.com/registry/deploying/
# https://hub.docker.com/_/registry
#
# Use host networking so the buildx container can access the registry via
# localhost.
#
docker run -d --name registry --network host registry:2 # defaults to port 5000
# Docker Hub sets a `DOCKER_REPO` env var with the format `index.docker.io/user/repo`.
# Strip the registry portion to construct a local repo path for use in `Dockerfile.buildx`.
LOCAL_REGISTRY="localhost:5000"
REPO="${DOCKER_REPO#*/}"
LOCAL_REPO="${LOCAL_REGISTRY}/${REPO}"
echo ">>> Pushing images to local registry..."
for arch in ${arches[@]}; do for arch in ${arches[@]}; do
images[$arch]="${DOCKER_REPO}:${DOCKER_TAG}-${arch}" docker_image="${DOCKER_REPO}:${DOCKER_TAG}-${arch}"
local_image="${LOCAL_REPO}:${DOCKER_TAG}-${arch}"
docker tag "${docker_image}" "${local_image}"
docker push "${local_image}"
done done
# Push the images that were just built; manifest list creation fails if the echo ">>> Setting up Docker Buildx..."
# images (manifests) referenced don't already exist in the Docker registry.
for image in "${images[@]}"; do
docker push "${image}"
done
manifest_lists=("${DOCKER_REPO}:${DOCKER_TAG}") # Same as earlier, use host networking so the buildx container can access the
# registry via localhost.
#
# Ref: https://github.com/docker/buildx/issues/94#issuecomment-534367714
#
docker buildx create --name builder --use --driver-opt network=host
# If the Docker tag starts with a version number, assume the latest release is echo ">>> Running Docker Buildx..."
# being pushed. Add an extra manifest (`latest` or `alpine`, as appropriate)
tags=("${DOCKER_REPO}:${DOCKER_TAG}")
# If the Docker tag starts with a version number, assume the latest release
# is being pushed. Add an extra tag (`latest` or `alpine`, as appropriate)
# to make it easier for users to track the latest release. # to make it easier for users to track the latest release.
if [[ "${DOCKER_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then if [[ "${DOCKER_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
if [[ "${DOCKER_TAG}" == *alpine ]]; then if [[ "${DOCKER_TAG}" == *alpine ]]; then
manifest_lists+=(${DOCKER_REPO}:alpine) tags+=(${DOCKER_REPO}:alpine)
else else
manifest_lists+=(${DOCKER_REPO}:latest) tags+=(${DOCKER_REPO}:latest)
# Add an extra `latest-arm32v6` tag; Docker can't seem to properly
# auto-select that image on Armv6 platforms like Raspberry Pi 1 and Zero
# (https://github.com/moby/moby/issues/41017).
#
# Add this tag only for the SQLite image, as the MySQL and PostgreSQL
# builds don't currently work on non-amd64 arches.
#
# TODO: Also add an `alpine-arm32v6` tag if multi-arch support for
# Alpine-based bitwarden_rs images is implemented before this Docker
# issue is fixed.
if [[ ${DOCKER_REPO} == *server ]]; then
docker tag "${DOCKER_REPO}:${DOCKER_TAG}-arm32v6" "${DOCKER_REPO}:latest-arm32v6"
docker push "${DOCKER_REPO}:latest-arm32v6"
fi
fi fi
fi fi
for manifest_list in "${manifest_lists[@]}"; do tag_args=()
# Create the (multi-arch) manifest list of arch-specific images. for tag in "${tags[@]}"; do
docker manifest create ${manifest_list} ${images[@]} tag_args+=(--tag "${tag}")
# Make sure each image manifest is annotated with the correct arch info.
# Docker does not auto-detect the arch of each cross-compiled image, so
# everything would appear as `linux/amd64` otherwise.
for arch in "${arches[@]}"; do
docker manifest annotate ${annotations[$arch]} ${manifest_list} ${images[$arch]}
done
# Push the manifest list.
docker manifest push --purge ${manifest_list}
done done
# Avoid logging credentials and tokens. # Docker Buildx takes a list of target platforms (OS/arch/variant), so map
set +ex # the arch list to a platform list (assuming the OS is always `linux`).
declare -A arch_to_platform=(
# Delete the arch-specific tags, if credentials for doing so are available. [amd64]="linux/amd64"
# Note that `DOCKER_PASSWORD` must be the actual user password. Passing a JWT [armv6]="linux/arm/v6"
# obtained using a personal access token results in a 403 error with [armv7]="linux/arm/v7"
# {"detail": "access to the resource is forbidden with personal access token"} [arm64]="linux/arm64"
if [[ -z "${DOCKER_USERNAME}" || -z "${DOCKER_PASSWORD}" ]]; then )
exit 0 platforms=()
fi
# Given a JSON input on stdin, extract the string value associated with the
# specified key. This avoids an extra dependency on a tool like `jq`.
extract() {
local key="$1"
# Extract "<key>":"<val>" (assumes key/val won't contain double quotes).
# The colon may have whitespace on either side.
grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]\+\"" |
# Extract just <val> by deleting the last '"', and then greedily deleting
# everything up to '"'.
sed -e 's/"$//' -e 's/.*"//'
}
echo ">>> Getting API token..."
jwt=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"${DOCKER_USERNAME}\",\"password\": \"${DOCKER_PASSWORD}\"}" \
"https://hub.docker.com/v2/users/login" |
extract 'token')
# Strip the registry portion from `index.docker.io/user/repo`.
repo="${DOCKER_REPO#*/}"
for arch in ${arches[@]}; do for arch in ${arches[@]}; do
# Don't delete the `arm32v6` tag; Docker can't seem to properly platforms+=("${arch_to_platform[$arch]}")
# auto-select that image on Armv6 platforms like Raspberry Pi 1 and Zero
# (https://github.com/moby/moby/issues/41017).
if [[ ${arch} == 'arm32v6' ]]; then
continue
fi
tag="${DOCKER_TAG}-${arch}"
echo ">>> Deleting '${repo}:${tag}'..."
curl -sS -X DELETE \
-H "Authorization: Bearer ${jwt}" \
"https://hub.docker.com/v2/repositories/${repo}/tags/${tag}/"
done done
platforms="$(join "," "${platforms[@]}")"
# Run the build, pushing the resulting images and multi-arch manifest list to
# Docker Hub. The Dockerfile is read from stdin to avoid sending any build
# context, which isn't needed here since the actual cross-compiled images
# have already been built.
docker buildx build \
--network host \
--build-arg LOCAL_REPO="${LOCAL_REPO}" \
--build-arg DOCKER_TAG="${DOCKER_TAG}" \
--platform "${platforms}" \
"${tag_args[@]}" \
--push \
- < ./docker/Dockerfile.buildx
# Add an extra arch-specific tag for `arm32v6`; Docker can't seem to properly
# auto-select that image on ARMv6 platforms like Raspberry Pi 1 and Zero
# (https://github.com/moby/moby/issues/41017).
#
# Note that we use `arm32v6` instead of `armv6` to be consistent with the
# existing vaultwarden tags, which adhere to the naming conventions of the
# Docker per-architecture repos (e.g., https://hub.docker.com/u/arm32v6).
# Unfortunately, these per-arch repo names aren't always consistent with the
# corresponding platform (OS/arch/variant) IDs, particularly in the case of
# 32-bit ARM arches (e.g., `linux/arm/v6` is used, not `linux/arm32/v6`).
#
# TODO: It looks like this issue should be fixed starting in Docker 20.10.0,
# so this step can be removed once fixed versions are in wider distribution.
#
# Tags:
#
# testing => testing-arm32v6
# testing-alpine => <ignored>
# x.y.z => x.y.z-arm32v6, latest-arm32v6
# x.y.z-alpine => <ignored>
#
if [[ "${DOCKER_TAG}" != *alpine ]]; then
image="${DOCKER_REPO}":"${DOCKER_TAG}"
# Fetch the multi-arch manifest list and find the digest of the armv6 image.
filter='.manifests|.[]|select(.platform.architecture=="arm" and .platform.variant=="v6")|.digest'
digest="$(docker manifest inspect "${image}" | jq -r "${filter}")"
# Pull the armv6 image by digest, retag it, and repush it.
docker pull "${DOCKER_REPO}"@"${digest}"
docker tag "${DOCKER_REPO}"@"${digest}" "${image}"-arm32v6
docker push "${image}"-arm32v6
if [[ "${DOCKER_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
docker tag "${image}"-arm32v6 "${DOCKER_REPO}:latest"-arm32v6
docker push "${DOCKER_REPO}:latest"-arm32v6
fi
fi

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL;

View File

@@ -0,0 +1 @@
DROP TABLE sends;

View File

@@ -0,0 +1,25 @@
CREATE TABLE sends (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) REFERENCES users (uuid),
organization_uuid CHAR(36) REFERENCES organizations (uuid),
name TEXT NOT NULL,
notes TEXT,
atype INTEGER NOT NULL,
data TEXT NOT NULL,
akey TEXT NOT NULL,
password_hash BLOB,
password_salt BLOB,
password_iter INTEGER,
max_access_count INTEGER,
access_count INTEGER NOT NULL,
creation_date DATETIME NOT NULL,
revision_date DATETIME NOT NULL,
expiration_date DATETIME,
deletion_date DATETIME NOT NULL,
disabled BOOLEAN NOT NULL
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE ciphers
ADD COLUMN reprompt INTEGER;

View File

@@ -0,0 +1,2 @@
ALTER TABLE sends
ADD COLUMN hide_email BOOLEAN;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL;

View File

@@ -0,0 +1 @@
DROP TABLE sends;

View File

@@ -0,0 +1,25 @@
CREATE TABLE sends (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) REFERENCES users (uuid),
organization_uuid CHAR(36) REFERENCES organizations (uuid),
name TEXT NOT NULL,
notes TEXT,
atype INTEGER NOT NULL,
data TEXT NOT NULL,
key TEXT NOT NULL,
password_hash BYTEA,
password_salt BYTEA,
password_iter INTEGER,
max_access_count INTEGER,
access_count INTEGER NOT NULL,
creation_date TIMESTAMP NOT NULL,
revision_date TIMESTAMP NOT NULL,
expiration_date TIMESTAMP,
deletion_date TIMESTAMP NOT NULL,
disabled BOOLEAN NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE sends RENAME COLUMN key TO akey;

View File

@@ -0,0 +1,2 @@
ALTER TABLE ciphers
ADD COLUMN reprompt INTEGER;

View File

@@ -0,0 +1,2 @@
ALTER TABLE sends
ADD COLUMN hide_email BOOLEAN;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL;

View File

@@ -0,0 +1 @@
DROP TABLE sends;

View File

@@ -0,0 +1,25 @@
CREATE TABLE sends (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT REFERENCES users (uuid),
organization_uuid TEXT REFERENCES organizations (uuid),
name TEXT NOT NULL,
notes TEXT,
atype INTEGER NOT NULL,
data TEXT NOT NULL,
key TEXT NOT NULL,
password_hash BLOB,
password_salt BLOB,
password_iter INTEGER,
max_access_count INTEGER,
access_count INTEGER NOT NULL,
creation_date DATETIME NOT NULL,
revision_date DATETIME NOT NULL,
expiration_date DATETIME,
deletion_date DATETIME NOT NULL,
disabled BOOLEAN NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE sends RENAME COLUMN key TO akey;

View File

@@ -0,0 +1,2 @@
ALTER TABLE ciphers
ADD COLUMN reprompt INTEGER;

View File

@@ -0,0 +1,2 @@
ALTER TABLE sends
ADD COLUMN hide_email BOOLEAN;

View File

@@ -1 +1 @@
nightly-2020-07-11 nightly-2021-06-24

View File

@@ -1,2 +1,7 @@
version = "Two" version = "Two"
edition = "2018"
max_width = 120 max_width = 120
newline_style = "Unix"
use_small_heuristics = "Off"
struct_lit_single_line = false
overflow_delimited_expr = true

View File

@@ -1,10 +1,10 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value; use serde_json::Value;
use std::process::Command; use std::{env, time::Duration};
use rocket::{ use rocket::{
http::{Cookie, Cookies, SameSite}, http::{Cookie, Cookies, SameSite, Status},
request::{self, FlashMessage, Form, FromRequest, Outcome, Request}, request::{self, FlashMessage, Form, FromRequest, Outcome, Request},
response::{content::Html, Flash, Redirect}, response::{content::Html, Flash, Redirect},
Route, Route,
@@ -12,13 +12,13 @@ use rocket::{
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use crate::{ use crate::{
api::{ApiResult, EmptyResult, JsonResult}, api::{ApiResult, EmptyResult, JsonResult, NumberOrString},
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp},
config::ConfigBuilder, config::ConfigBuilder,
db::{backup_database, models::*, DbConn, DbConnType}, db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType},
error::{Error, MapResult}, error::{Error, MapResult},
mail, mail,
util::get_display_size, util::{format_naive_datetime_local, get_display_size, get_reqwest_client, is_running_in_docker},
CONFIG, CONFIG,
}; };
@@ -30,13 +30,17 @@ pub fn routes() -> Vec<Route> {
routes![ routes![
admin_login, admin_login,
get_users_json, get_users_json,
get_user_json,
post_admin_login, post_admin_login,
admin_page, admin_page,
invite_user, invite_user,
logout, logout,
delete_user, delete_user,
deauth_user, deauth_user,
disable_user,
enable_user,
remove_2fa, remove_2fa,
update_user_org_type,
update_revision_users, update_revision_users,
post_config, post_config,
delete_config, delete_config,
@@ -44,17 +48,25 @@ pub fn routes() -> Vec<Route> {
test_smtp, test_smtp,
users_overview, users_overview,
organizations_overview, organizations_overview,
delete_organization,
diagnostics, diagnostics,
get_diagnostics_config
] ]
} }
static CAN_BACKUP: Lazy<bool> = Lazy::new(|| { static DB_TYPE: Lazy<&str> = Lazy::new(|| {
DbConnType::from_url(&CONFIG.database_url()) DbConnType::from_url(&CONFIG.database_url())
.map(|t| t == DbConnType::sqlite) .map(|t| match t {
.unwrap_or(false) DbConnType::sqlite => "SQLite",
&& Command::new("sqlite3").arg("-version").status().is_ok() DbConnType::mysql => "MySQL",
DbConnType::postgresql => "PostgreSQL",
})
.unwrap_or("Unknown")
}); });
static CAN_BACKUP: Lazy<bool> =
Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false));
#[get("/")] #[get("/")]
fn admin_disabled() -> &'static str { fn admin_disabled() -> &'static str {
"The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it" "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"
@@ -80,6 +92,27 @@ impl<'a, 'r> FromRequest<'a, 'r> for Referer {
} }
} }
#[derive(Debug)]
struct IpHeader(Option<String>);
impl<'a, 'r> FromRequest<'a, 'r> for IpHeader {
type Error = ();
fn from_request(req: &'a Request<'r>) -> Outcome<Self, Self::Error> {
if req.headers().get_one(&CONFIG.ip_header()).is_some() {
Outcome::Success(IpHeader(Some(CONFIG.ip_header())))
} else if req.headers().get_one("X-Client-IP").is_some() {
Outcome::Success(IpHeader(Some(String::from("X-Client-IP"))))
} else if req.headers().get_one("X-Real-IP").is_some() {
Outcome::Success(IpHeader(Some(String::from("X-Real-IP"))))
} else if req.headers().get_one("X-Forwarded-For").is_some() {
Outcome::Success(IpHeader(Some(String::from("X-Forwarded-For"))))
} else {
Outcome::Success(IpHeader(None))
}
}
}
/// Used for `Location` response headers, which must specify an absolute URI /// Used for `Location` response headers, which must specify an absolute URI
/// (see https://tools.ietf.org/html/rfc2616#section-14.30). /// (see https://tools.ietf.org/html/rfc2616#section-14.30).
fn admin_url(referer: Referer) -> String { fn admin_url(referer: Referer) -> String {
@@ -105,7 +138,12 @@ fn admin_url(referer: Referer) -> String {
fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> { fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> {
// If there is an error, show it // If there is an error, show it
let msg = flash.map(|msg| format!("{}: {}", msg.name(), msg.msg())); let msg = flash.map(|msg| format!("{}: {}", msg.name(), msg.msg()));
let json = json!({"page_content": "admin/login", "version": VERSION, "error": msg, "urlpath": CONFIG.domain_path()}); let json = json!({
"page_content": "admin/login",
"version": VERSION,
"error": msg,
"urlpath": CONFIG.domain_path()
});
// Return the page // Return the page
let text = CONFIG.render_template(BASE_TEMPLATE, &json)?; let text = CONFIG.render_template(BASE_TEMPLATE, &json)?;
@@ -129,10 +167,7 @@ fn post_admin_login(
// If the token is invalid, redirect to login page // If the token is invalid, redirect to login page
if !_validate_token(&data.token) { if !_validate_token(&data.token) {
error!("Invalid admin token. IP: {}", ip.ip); error!("Invalid admin token. IP: {}", ip.ip);
Err(Flash::error( Err(Flash::error(Redirect::to(admin_url(referer)), "Invalid admin token, please try again."))
Redirect::to(admin_url(referer)),
"Invalid admin token, please try again.",
))
} else { } else {
// If the token received is valid, generate JWT and save it as a cookie // If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims(); let claims = generate_admin_claims();
@@ -161,9 +196,7 @@ fn _validate_token(token: &str) -> bool {
struct AdminTemplateData { struct AdminTemplateData {
page_content: String, page_content: String,
version: Option<&'static str>, version: Option<&'static str>,
users: Option<Vec<Value>>, page_data: Option<Value>,
organizations: Option<Vec<Value>>,
diagnostics: Option<Value>,
config: Value, config: Value,
can_backup: bool, can_backup: bool,
logged_in: bool, logged_in: bool,
@@ -179,51 +212,19 @@ impl AdminTemplateData {
can_backup: *CAN_BACKUP, can_backup: *CAN_BACKUP,
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
users: None, page_data: None,
organizations: None,
diagnostics: None,
} }
} }
fn users(users: Vec<Value>) -> Self { fn with_data(page_content: &str, page_data: Value) -> Self {
Self { Self {
page_content: String::from("admin/users"), page_content: String::from(page_content),
version: VERSION, version: VERSION,
users: Some(users), page_data: Some(page_data),
config: CONFIG.prepare_json(), config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP, can_backup: *CAN_BACKUP,
logged_in: true, logged_in: true,
urlpath: CONFIG.domain_path(), urlpath: CONFIG.domain_path(),
organizations: None,
diagnostics: None,
}
}
fn organizations(organizations: Vec<Value>) -> Self {
Self {
page_content: String::from("admin/organizations"),
version: VERSION,
organizations: Some(organizations),
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
users: None,
diagnostics: None,
}
}
fn diagnostics(diagnostics: Value) -> Self {
Self {
page_content: String::from("admin/diagnostics"),
version: VERSION,
organizations: None,
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
users: None,
diagnostics: Some(diagnostics),
} }
} }
@@ -244,23 +245,39 @@ struct InviteData {
email: String, email: String,
} }
fn get_user_or_404(uuid: &str, conn: &DbConn) -> ApiResult<User> {
if let Some(user) = User::find_by_uuid(uuid, conn) {
Ok(user)
} else {
err_code!("User doesn't exist", Status::NotFound.code);
}
}
#[post("/invite", data = "<data>")] #[post("/invite", data = "<data>")]
fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> EmptyResult { fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
let data: InviteData = data.into_inner(); let data: InviteData = data.into_inner();
let email = data.email.clone(); let email = data.email.clone();
if User::find_by_mail(&data.email, &conn).is_some() { if User::find_by_mail(&data.email, &conn).is_some() {
err!("User already exists") err_code!("User already exists", Status::Conflict.code)
} }
let mut user = User::new(email); let mut user = User::new(email);
user.save(&conn)?;
if CONFIG.mail_enabled() { // TODO: After try_blocks is stabilized, this can be made more readable
mail::send_invite(&user.email, &user.uuid, None, None, &CONFIG.invitation_org_name(), None) // See: https://github.com/rust-lang/rust/issues/31436
} else { (|| {
let invitation = Invitation::new(data.email); if CONFIG.mail_enabled() {
invitation.save(&conn) mail::send_invite(&user.email, &user.uuid, None, None, &CONFIG.invitation_org_name(), None)?;
} } else {
let invitation = Invitation::new(data.email);
invitation.save(&conn)?;
}
user.save(&conn)
})()
.map_err(|e| e.with_code(Status::InternalServerError.code))?;
Ok(Json(user.to_json(&conn)))
} }
#[post("/test/smtp", data = "<data>")] #[post("/test/smtp", data = "<data>")]
@@ -275,58 +292,126 @@ fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
} }
#[get("/logout")] #[get("/logout")]
fn logout(mut cookies: Cookies, referer: Referer) -> Result<Redirect, ()> { fn logout(mut cookies: Cookies, referer: Referer) -> Redirect {
cookies.remove(Cookie::named(COOKIE_NAME)); cookies.remove(Cookie::named(COOKIE_NAME));
Ok(Redirect::to(admin_url(referer))) Redirect::to(admin_url(referer))
} }
#[get("/users")] #[get("/users")]
fn get_users_json(_token: AdminToken, conn: DbConn) -> JsonResult { fn get_users_json(_token: AdminToken, conn: DbConn) -> Json<Value> {
let users = User::get_all(&conn); let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect(); let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
Ok(Json(Value::Array(users_json))) Json(Value::Array(users_json))
} }
#[get("/users/overview")] #[get("/users/overview")]
fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> { fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let users = User::get_all(&conn); let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter() let dt_fmt = "%Y-%m-%d %H:%M:%S %Z";
let users_json: Vec<Value> = users
.iter()
.map(|u| { .map(|u| {
let mut usr = u.to_json(&conn); let mut usr = u.to_json(&conn);
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn)); usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn));
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn)); usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn));
usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn) as i32)); usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn) as i32));
usr["user_enabled"] = json!(u.enabled);
usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, dt_fmt));
usr["last_active"] = match u.last_active(&conn) {
Some(dt) => json!(format_naive_datetime_local(&dt, dt_fmt)),
None => json!("Never"),
};
usr usr
}).collect(); })
.collect();
let text = AdminTemplateData::users(users_json).render()?; let text = AdminTemplateData::with_data("admin/users", json!(users_json)).render()?;
Ok(Html(text)) Ok(Html(text))
} }
#[get("/users/<uuid>")]
fn get_user_json(uuid: String, _token: AdminToken, conn: DbConn) -> JsonResult {
let user = get_user_or_404(&uuid, &conn)?;
Ok(Json(user.to_json(&conn)))
}
#[post("/users/<uuid>/delete")] #[post("/users/<uuid>/delete")]
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?; let user = get_user_or_404(&uuid, &conn)?;
user.delete(&conn) user.delete(&conn)
} }
#[post("/users/<uuid>/deauth")] #[post("/users/<uuid>/deauth")]
fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?; let mut user = get_user_or_404(&uuid, &conn)?;
Device::delete_all_by_user(&user.uuid, &conn)?; Device::delete_all_by_user(&user.uuid, &conn)?;
user.reset_security_stamp(); user.reset_security_stamp();
user.save(&conn) user.save(&conn)
} }
#[post("/users/<uuid>/disable")]
fn disable_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&uuid, &conn)?;
Device::delete_all_by_user(&user.uuid, &conn)?;
user.reset_security_stamp();
user.enabled = false;
user.save(&conn)
}
#[post("/users/<uuid>/enable")]
fn enable_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&uuid, &conn)?;
user.enabled = true;
user.save(&conn)
}
#[post("/users/<uuid>/remove-2fa")] #[post("/users/<uuid>/remove-2fa")]
fn remove_2fa(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { fn remove_2fa(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?; let mut user = get_user_or_404(&uuid, &conn)?;
TwoFactor::delete_all_by_user(&user.uuid, &conn)?; TwoFactor::delete_all_by_user(&user.uuid, &conn)?;
user.totp_recover = None; user.totp_recover = None;
user.save(&conn) user.save(&conn)
} }
#[derive(Deserialize, Debug)]
struct UserOrgTypeData {
user_type: NumberOrString,
user_uuid: String,
org_uuid: String,
}
#[post("/users/org_type", data = "<data>")]
fn update_user_org_type(data: Json<UserOrgTypeData>, _token: AdminToken, conn: DbConn) -> EmptyResult {
let data: UserOrgTypeData = data.into_inner();
let mut user_to_edit = match UserOrganization::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn) {
Some(user) => user,
None => err!("The specified user isn't member of the organization"),
};
let new_type = match UserOrgType::from_str(&data.user_type.into_string()) {
Some(new_type) => new_type as i32,
None => err!("Invalid type"),
};
if user_to_edit.atype == UserOrgType::Owner && new_type != UserOrgType::Owner {
// Removing owner permmission, check that there are at least another owner
let num_owners = UserOrganization::find_by_org_and_type(&data.org_uuid, UserOrgType::Owner as i32, &conn).len();
if num_owners <= 1 {
err!("Can't change the type of the last owner")
}
}
user_to_edit.atype = new_type as i32;
user_to_edit.save(&conn)
}
#[post("/users/update_revision")] #[post("/users/update_revision")]
fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult { fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
User::update_all_revisions(&conn) User::update_all_revisions(&conn)
@@ -335,19 +420,28 @@ fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
#[get("/organizations/overview")] #[get("/organizations/overview")]
fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> { fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let organizations = Organization::get_all(&conn); let organizations = Organization::get_all(&conn);
let organizations_json: Vec<Value> = organizations.iter().map(|o| { let organizations_json: Vec<Value> = organizations
.iter()
.map(|o| {
let mut org = o.to_json(); let mut org = o.to_json();
org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &conn)); org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &conn));
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn)); org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn));
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn)); org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn));
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn) as i32)); org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn) as i32));
org org
}).collect(); })
.collect();
let text = AdminTemplateData::organizations(organizations_json).render()?; let text = AdminTemplateData::with_data("admin/organizations", json!(organizations_json)).render()?;
Ok(Html(text)) Ok(Html(text))
} }
#[post("/organizations/<uuid>/delete")]
fn delete_organization(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let org = Organization::find_by_uuid(&uuid, &conn).map_res("Organization doesn't exist")?;
org.delete(&conn)
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct WebVaultVersion { struct WebVaultVersion {
version: String, version: String,
@@ -364,77 +458,121 @@ struct GitCommit {
} }
fn get_github_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> { fn get_github_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
use reqwest::{blocking::Client, header::USER_AGENT}; let github_api = get_reqwest_client();
use std::time::Duration;
let github_api = Client::builder().build()?;
Ok( Ok(github_api.get(url).timeout(Duration::from_secs(10)).send()?.error_for_status()?.json::<T>()?)
github_api.get(url) }
.timeout(Duration::from_secs(10))
.header(USER_AGENT, "Bitwarden_RS") fn has_http_access() -> bool {
.send()? let http_access = get_reqwest_client();
.error_for_status()?
.json::<T>()? match http_access.head("https://github.com/dani-garcia/vaultwarden").timeout(Duration::from_secs(10)).send() {
) Ok(r) => r.status().is_success(),
_ => false,
}
} }
#[get("/diagnostics")] #[get("/diagnostics")]
fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> { fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use std::net::ToSocketAddrs;
use chrono::prelude::*;
use crate::util::read_file_string; use crate::util::read_file_string;
use chrono::prelude::*;
use std::net::ToSocketAddrs;
let vault_version_path = format!("{}/{}", CONFIG.web_vault_folder(), "version.json"); // Get current running versions
let vault_version_str = read_file_string(&vault_version_path)?; let web_vault_version: WebVaultVersion =
let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?; match read_file_string(&format!("{}/{}", CONFIG.web_vault_folder(), "bwrs-version.json")) {
Ok(s) => serde_json::from_str(&s)?,
_ => match read_file_string(&format!("{}/{}", CONFIG.web_vault_folder(), "version.json")) {
Ok(s) => serde_json::from_str(&s)?,
_ => WebVaultVersion {
version: String::from("Version file missing"),
},
},
};
let github_ips = ("github.com", 0).to_socket_addrs().map(|mut i| i.next()); // Execute some environment checks
let (dns_resolved, dns_ok) = match github_ips { let running_within_docker = is_running_in_docker();
Ok(Some(a)) => (a.ip().to_string(), true), let has_http_access = has_http_access();
_ => ("Could not resolve domain name.".to_string(), false), let uses_proxy = env::var_os("HTTP_PROXY").is_some()
|| env::var_os("http_proxy").is_some()
|| env::var_os("HTTPS_PROXY").is_some()
|| env::var_os("https_proxy").is_some();
// Check if we are able to resolve DNS entries
let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
Ok(Some(a)) => a.ip().to_string(),
_ => "Could not resolve domain name.".to_string(),
}; };
// If the DNS Check failed, do not even attempt to check for new versions since we were not able to resolve github.com // 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.
let (latest_release, latest_commit, latest_web_build) = if dns_ok { // TODO: Maybe we need to cache this using a LazyStatic or something. Github only allows 60 requests per hour, and we use 3 here already.
let (latest_release, latest_commit, latest_web_build) = if has_http_access {
( (
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bitwarden_rs/releases/latest") { match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/vaultwarden/releases/latest") {
Ok(r) => r.tag_name, Ok(r) => r.tag_name,
_ => "-".to_string() _ => "-".to_string(),
}, },
match get_github_api::<GitCommit>("https://api.github.com/repos/dani-garcia/bitwarden_rs/commits/master") { match get_github_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main") {
Ok(mut c) => { Ok(mut c) => {
c.sha.truncate(8); c.sha.truncate(8);
c.sha c.sha
}
_ => "-".to_string(),
}, },
_ => "-".to_string() // Do not fetch the web-vault version when running within Docker.
}, // The web-vault version is embedded within the container it self, and should not be updated manually
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") { if running_within_docker {
Ok(r) => r.tag_name.trim_start_matches('v').to_string(), "-".to_string()
_ => "-".to_string() } else {
match get_github_api::<GitRelease>(
"https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest",
) {
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
_ => "-".to_string(),
}
}, },
) )
} else { } else {
("-".to_string(), "-".to_string(), "-".to_string()) ("-".to_string(), "-".to_string(), "-".to_string())
}; };
// Run the date check as the last item right before filling the json. let ip_header_name = match &ip_header.0 {
// This should ensure that the time difference between the browser and the server is as minimal as possible. Some(h) => h,
let dt = Utc::now(); _ => "",
let server_time = dt.format("%Y-%m-%d %H:%M:%S").to_string(); };
let diagnostics_json = json!({ let diagnostics_json = json!({
"dns_resolved": dns_resolved, "dns_resolved": dns_resolved,
"server_time": server_time,
"web_vault_version": web_vault_version.version,
"latest_release": latest_release, "latest_release": latest_release,
"latest_commit": latest_commit, "latest_commit": latest_commit,
"web_vault_enabled": &CONFIG.web_vault_enabled(),
"web_vault_version": web_vault_version.version,
"latest_web_build": latest_web_build, "latest_web_build": latest_web_build,
"running_within_docker": running_within_docker,
"has_http_access": has_http_access,
"ip_header_exists": &ip_header.0.is_some(),
"ip_header_match": ip_header_name == CONFIG.ip_header(),
"ip_header_name": ip_header_name,
"ip_header_config": &CONFIG.ip_header(),
"uses_proxy": uses_proxy,
"db_type": *DB_TYPE,
"db_version": get_sql_server_version(&conn),
"admin_url": format!("{}/diagnostics", admin_url(Referer(None))),
"overrides": &CONFIG.get_overrides().join(", "),
"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 date/time check as the last item to minimize the difference
}); });
let text = AdminTemplateData::diagnostics(diagnostics_json).render()?; let text = AdminTemplateData::with_data("admin/diagnostics", diagnostics_json).render()?;
Ok(Html(text)) Ok(Html(text))
} }
#[get("/diagnostics/config")]
fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
let support_json = CONFIG.get_support_json();
Json(support_json)
}
#[post("/config", data = "<data>")] #[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult { fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner(); let data: ConfigBuilder = data.into_inner();
@@ -447,11 +585,11 @@ fn delete_config(_token: AdminToken) -> EmptyResult {
} }
#[post("/config/backup_db")] #[post("/config/backup_db")]
fn backup_db(_token: AdminToken) -> EmptyResult { fn backup_db(_token: AdminToken, conn: DbConn) -> EmptyResult {
if *CAN_BACKUP { if *CAN_BACKUP {
backup_database() backup_database(&conn)
} else { } else {
err!("Can't back up current DB (either it's not SQLite or the 'sqlite' binary is not present)"); err!("Can't back up current DB (Only SQLite supports this feature)");
} }
} }

View File

@@ -1,5 +1,6 @@
use chrono::Utc; use chrono::Utc;
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use serde_json::Value;
use crate::{ use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}, api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType},
@@ -94,7 +95,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
} }
None => { None => {
// Order is important here; the invitation check must come first // Order is important here; the invitation check must come first
// because the bitwarden_rs admin can invite anyone, regardless // because the vaultwarden admin can invite anyone, regardless
// of other signup restrictions. // of other signup restrictions.
if Invitation::take(&data.Email, &conn) || CONFIG.is_signup_allowed(&data.Email) { if Invitation::take(&data.Email, &conn) || CONFIG.is_signup_allowed(&data.Email) {
User::new(data.Email.clone()) User::new(data.Email.clone())
@@ -115,7 +116,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
user.client_kdf_type = client_kdf_type; user.client_kdf_type = client_kdf_type;
} }
user.set_password(&data.MasterPasswordHash); user.set_password(&data.MasterPasswordHash, None);
user.akey = data.Key; user.akey = data.Key;
// Add extra fields if present // Add extra fields if present
@@ -139,10 +140,8 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
} }
user.last_verifying_at = Some(user.created_at); user.last_verifying_at = Some(user.created_at);
} else { } else if let Err(e) = mail::send_welcome(&user.email) {
if let Err(e) = mail::send_welcome(&user.email) { error!("Error sending welcome email: {:#?}", e);
error!("Error sending welcome email: {:#?}", e);
}
} }
} }
@@ -150,8 +149,8 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
} }
#[get("/accounts/profile")] #[get("/accounts/profile")]
fn profile(headers: Headers, conn: DbConn) -> JsonResult { fn profile(headers: Headers, conn: DbConn) -> Json<Value> {
Ok(Json(headers.user.to_json(&conn))) Json(headers.user.to_json(&conn))
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -232,7 +231,7 @@ fn post_password(data: JsonUpcase<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid password") err!("Invalid password")
} }
user.set_password(&data.NewMasterPasswordHash); user.set_password(&data.NewMasterPasswordHash, Some("post_rotatekey"));
user.akey = data.Key; user.akey = data.Key;
user.save(&conn) user.save(&conn)
} }
@@ -259,7 +258,7 @@ fn post_kdf(data: JsonUpcase<ChangeKdfData>, headers: Headers, conn: DbConn) ->
user.client_kdf_iter = data.KdfIterations; user.client_kdf_iter = data.KdfIterations;
user.client_kdf_type = data.Kdf; user.client_kdf_type = data.Kdf;
user.set_password(&data.NewMasterPasswordHash); user.set_password(&data.NewMasterPasswordHash, None);
user.akey = data.Key; user.akey = data.Key;
user.save(&conn) user.save(&conn)
} }
@@ -321,15 +320,7 @@ fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, conn: DbConn, nt:
err!("The cipher is not owned by the user") err!("The cipher is not owned by the user")
} }
update_cipher_from_data( update_cipher_from_data(&mut saved_cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::CipherUpdate)?
&mut saved_cipher,
cipher_data,
&headers,
false,
&conn,
&nt,
UpdateType::CipherUpdate,
)?
} }
// Update user data // Update user data
@@ -338,6 +329,7 @@ fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, conn: DbConn, nt:
user.akey = data.Key; user.akey = data.Key;
user.private_key = Some(data.PrivateKey); user.private_key = Some(data.PrivateKey);
user.reset_security_stamp(); user.reset_security_stamp();
user.reset_stamp_exception();
user.save(&conn) user.save(&conn)
} }
@@ -445,7 +437,7 @@ fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn)
user.email_new = None; user.email_new = None;
user.email_new_token = None; user.email_new_token = None;
user.set_password(&data.NewMasterPasswordHash); user.set_password(&data.NewMasterPasswordHash, None);
user.akey = data.Key; user.akey = data.Key;
user.save(&conn) user.save(&conn)
@@ -460,7 +452,7 @@ fn post_verify_email(headers: Headers, _conn: DbConn) -> EmptyResult {
} }
if let Err(e) = mail::send_verify_email(&user.email, &user.uuid) { if let Err(e) = mail::send_verify_email(&user.email, &user.uuid) {
error!("Error sending delete account email: {:#?}", e); error!("Error sending verify_email email: {:#?}", e);
} }
Ok(()) Ok(())
@@ -611,7 +603,7 @@ struct PreloginData {
} }
#[post("/accounts/prelogin", data = "<data>")] #[post("/accounts/prelogin", data = "<data>")]
fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> JsonResult { fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> Json<Value> {
let data: PreloginData = data.into_inner().data; let data: PreloginData = data.into_inner().data;
let (kdf_type, kdf_iter) = match User::find_by_mail(&data.Email, &conn) { let (kdf_type, kdf_iter) = match User::find_by_mail(&data.Email, &conn) {
@@ -619,10 +611,10 @@ fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> JsonResult {
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT), None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT),
}; };
Ok(Json(json!({ Json(json!({
"Kdf": kdf_type, "Kdf": kdf_type,
"KdfIterations": kdf_iter "KdfIterations": kdf_iter
}))) }))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(non_snake_case)] #[allow(non_snake_case)]

View File

@@ -1,22 +1,32 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::{Path, PathBuf};
use chrono::{NaiveDateTime, Utc};
use rocket::{http::ContentType, request::Form, Data, Route}; use rocket::{http::ContentType, request::Form, Data, Route};
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use serde_json::Value; use serde_json::Value;
use data_encoding::HEXLOWER;
use multipart::server::{save::SavedData, Multipart, SaveResult}; use multipart::server::{save::SavedData, Multipart, SaveResult};
use crate::{ use crate::{
api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType}, api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType},
auth::Headers, auth::Headers,
crypto, crypto,
db::{models::*, DbConn}, db::{models::*, DbConn, DbPool},
CONFIG, CONFIG,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
// because the stored procedure that upstream Bitwarden uses to determine
// whether the user can edit a cipher doesn't take into account whether
// the user is an org owner/admin. The `admin` variant first checks
// whether the user is an owner/admin of the relevant org, and if so,
// allows the operation unconditionally.
//
// vaultwarden factors in the org owner/admin status as part of
// determining the write accessibility of a cipher, so most
// admin/non-admin implementations can be shared.
routes![ routes![
sync, sync,
get_ciphers, get_ciphers,
@@ -28,8 +38,11 @@ pub fn routes() -> Vec<Route> {
post_ciphers_admin, post_ciphers_admin,
post_ciphers_create, post_ciphers_create,
post_ciphers_import, post_ciphers_import,
post_attachment, get_attachment,
post_attachment_admin, post_attachment_v2,
post_attachment_v2_data,
post_attachment, // legacy
post_attachment_admin, // legacy
post_attachment_share, post_attachment_share,
delete_attachment_post, delete_attachment_post,
delete_attachment_post_admin, delete_attachment_post_admin,
@@ -38,7 +51,7 @@ pub fn routes() -> Vec<Route> {
post_cipher_admin, post_cipher_admin,
post_cipher_share, post_cipher_share,
put_cipher_share, put_cipher_share,
put_cipher_share_seleted, put_cipher_share_selected,
post_cipher, post_cipher,
put_cipher, put_cipher,
delete_cipher_post, delete_cipher_post,
@@ -50,6 +63,9 @@ pub fn routes() -> Vec<Route> {
delete_cipher_selected, delete_cipher_selected,
delete_cipher_selected_post, delete_cipher_selected_post,
delete_cipher_selected_put, delete_cipher_selected_put,
delete_cipher_selected_admin,
delete_cipher_selected_post_admin,
delete_cipher_selected_put_admin,
restore_cipher_put, restore_cipher_put,
restore_cipher_put_admin, restore_cipher_put_admin,
restore_cipher_selected, restore_cipher_selected,
@@ -63,6 +79,15 @@ pub fn routes() -> Vec<Route> {
] ]
} }
pub fn purge_trashed_ciphers(pool: DbPool) {
debug!("Purging trashed ciphers");
if let Ok(conn) = pool.get() {
Cipher::purge_trash(&conn);
} else {
error!("Failed to get DB connection while purging trashed ciphers")
}
}
#[derive(FromForm, Default)] #[derive(FromForm, Default)]
struct SyncData { struct SyncData {
#[form(field = "excludeDomains")] #[form(field = "excludeDomains")]
@@ -70,55 +95,57 @@ struct SyncData {
} }
#[get("/sync?<data..>")] #[get("/sync?<data..>")]
fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult { fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> Json<Value> {
let user_json = headers.user.to_json(&conn); let user_json = headers.user.to_json(&conn);
let folders = Folder::find_by_user(&headers.user.uuid, &conn); let folders = Folder::find_by_user(&headers.user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect(); let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect();
let collections = Collection::find_by_user_uuid(&headers.user.uuid, &conn); let collections = Collection::find_by_user_uuid(&headers.user.uuid, &conn);
let collections_json: Vec<Value> = collections.iter().map(Collection::to_json).collect(); let collections_json: Vec<Value> =
collections.iter().map(|c| c.to_json_details(&headers.user.uuid, &conn)).collect();
let policies = OrgPolicy::find_by_user(&headers.user.uuid, &conn); let policies = OrgPolicy::find_by_user(&headers.user.uuid, &conn);
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect(); let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn); let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers let ciphers_json: Vec<Value> =
.iter() ciphers.iter().map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn)).collect();
.map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn))
.collect(); let sends = Send::find_by_user(&headers.user.uuid, &conn);
let sends_json: Vec<Value> = sends.iter().map(|s| s.to_json()).collect();
let domains_json = if data.exclude_domains { let domains_json = if data.exclude_domains {
Value::Null Value::Null
} else { } else {
api::core::_get_eq_domains(headers, true).unwrap().into_inner() api::core::_get_eq_domains(headers, true).into_inner()
}; };
Ok(Json(json!({ Json(json!({
"Profile": user_json, "Profile": user_json,
"Folders": folders_json, "Folders": folders_json,
"Collections": collections_json, "Collections": collections_json,
"Policies": policies_json, "Policies": policies_json,
"Ciphers": ciphers_json, "Ciphers": ciphers_json,
"Domains": domains_json, "Domains": domains_json,
"Sends": sends_json,
"unofficialServer": true,
"Object": "sync" "Object": "sync"
}))) }))
} }
#[get("/ciphers")] #[get("/ciphers")]
fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { fn get_ciphers(headers: Headers, conn: DbConn) -> Json<Value> {
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn); let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers let ciphers_json: Vec<Value> =
.iter() ciphers.iter().map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn)).collect();
.map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn))
.collect();
Ok(Json(json!({ Json(json!({
"Data": ciphers_json, "Data": ciphers_json,
"Object": "list", "Object": "list",
"ContinuationToken": null "ContinuationToken": null
}))) }))
} }
#[get("/ciphers/<uuid>")] #[get("/ciphers/<uuid>")]
@@ -174,6 +201,7 @@ pub struct CipherData {
Identity: Option<Value>, Identity: Option<Value>,
Favorite: Option<bool>, Favorite: Option<bool>,
Reprompt: Option<i32>,
PasswordHistory: Option<Value>, PasswordHistory: Option<Value>,
@@ -181,6 +209,14 @@ pub struct CipherData {
#[serde(rename = "Attachments")] #[serde(rename = "Attachments")]
_Attachments: Option<Value>, // Unused, contains map of {id: filename} _Attachments: Option<Value>, // Unused, contains map of {id: filename}
Attachments2: Option<HashMap<String, Attachments2Data>>, Attachments2: Option<HashMap<String, Attachments2Data>>,
// The revision datetime (in ISO 8601 format) of the client's local copy
// of the cipher. This is used to prevent a client from updating a cipher
// when it doesn't have the latest version, as that can result in data
// loss. It's not an error when no value is provided; this can happen
// when using older client versions, or if the operation doesn't involve
// updating an existing cipher.
LastKnownRevisionDate: Option<String>,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -190,25 +226,55 @@ pub struct Attachments2Data {
Key: String, Key: String,
} }
/// Called when an org admin clones an org cipher.
#[post("/ciphers/admin", data = "<data>")] #[post("/ciphers/admin", data = "<data>")]
fn post_ciphers_admin(data: JsonUpcase<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult { fn post_ciphers_admin(data: JsonUpcase<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
let data: ShareCipherData = data.into_inner().data; post_ciphers_create(data, headers, conn, nt)
}
/// Called when creating a new org-owned cipher, or cloning a cipher (whether
/// user- or org-owned). When cloning a cipher to a user-owned cipher,
/// `organizationId` is null.
#[post("/ciphers/create", data = "<data>")]
fn post_ciphers_create(data: JsonUpcase<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
let mut data: ShareCipherData = data.into_inner().data;
// Check if there are one more more collections selected when this cipher is part of an organization.
// err if this is not the case before creating an empty cipher.
if data.Cipher.OrganizationId.is_some() && data.CollectionIds.is_empty() {
err!("You must select at least one collection.");
}
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(&data.Cipher, &headers, &conn)?;
let mut cipher = Cipher::new(data.Cipher.Type, data.Cipher.Name.clone()); let mut cipher = Cipher::new(data.Cipher.Type, data.Cipher.Name.clone());
cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn)?; cipher.save(&conn)?;
// When cloning a cipher, the Bitwarden clients seem to set this field
// based on the cipher being cloned (when creating a new cipher, it's set
// to null as expected). However, `cipher.created_at` is initialized to
// the current time, so the stale data check will end up failing down the
// line. Since this function only creates new ciphers (whether by cloning
// or otherwise), we can just ignore this field entirely.
data.Cipher.LastKnownRevisionDate = None;
share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt) share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt)
} }
#[post("/ciphers/create", data = "<data>")] /// Called when creating a new user-owned cipher.
fn post_ciphers_create(data: JsonUpcase<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
post_ciphers_admin(data, headers, conn, nt)
}
#[post("/ciphers", data = "<data>")] #[post("/ciphers", data = "<data>")]
fn post_ciphers(data: JsonUpcase<CipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult { fn post_ciphers(data: JsonUpcase<CipherData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
let data: CipherData = data.into_inner().data; let mut data: CipherData = data.into_inner().data;
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't
// needed when creating a new cipher, so just ignore it unconditionally.
data.LastKnownRevisionDate = None;
let mut cipher = Cipher::new(data.Type, data.Name.clone()); let mut cipher = Cipher::new(data.Type, data.Name.clone());
update_cipher_from_data(&mut cipher, data, &headers, false, &conn, &nt, UpdateType::CipherCreate)?; update_cipher_from_data(&mut cipher, data, &headers, false, &conn, &nt, UpdateType::CipherCreate)?;
@@ -216,6 +282,24 @@ fn post_ciphers(data: JsonUpcase<CipherData>, headers: Headers, conn: DbConn, nt
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn))) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
} }
/// Enforces the personal ownership policy on user-owned ciphers, if applicable.
/// A non-owner/admin user belonging to an org with the personal ownership policy
/// enabled isn't allowed to create new user-owned ciphers or modify existing ones
/// (that were created before the policy was applicable to the user). The user is
/// allowed to delete or share such ciphers to an org, however.
///
/// Ref: https://bitwarden.com/help/article/policies/#personal-ownership
fn enforce_personal_ownership_policy(data: &CipherData, headers: &Headers, conn: &DbConn) -> EmptyResult {
if data.OrganizationId.is_none() {
let user_uuid = &headers.user.uuid;
let policy_type = OrgPolicyType::PersonalOwnership;
if OrgPolicy::is_applicable_to_user(user_uuid, policy_type, conn) {
err!("Due to an Enterprise Policy, you are restricted from saving items to your personal vault.")
}
}
Ok(())
}
pub fn update_cipher_from_data( pub fn update_cipher_from_data(
cipher: &mut Cipher, cipher: &mut Cipher,
data: CipherData, data: CipherData,
@@ -225,19 +309,38 @@ pub fn update_cipher_from_data(
nt: &Notify, nt: &Notify,
ut: UpdateType, ut: UpdateType,
) -> EmptyResult { ) -> EmptyResult {
enforce_personal_ownership_policy(&data, headers, conn)?;
// Check that the client isn't updating an existing cipher with stale data.
if let Some(dt) = data.LastKnownRevisionDate {
match NaiveDateTime::parse_from_str(&dt, "%+") {
// ISO 8601 format
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {}", dt, err),
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
err!("The client copy of this cipher is out of date. Resync the client and try again.")
}
Ok(_) => (),
}
}
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.OrganizationId { if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.OrganizationId {
err!("Organization mismatch. Please resync the client before updating the cipher") err!("Organization mismatch. Please resync the client before updating the cipher")
} }
if let Some(org_id) = data.OrganizationId { if let Some(org_id) = data.OrganizationId {
match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) { match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, conn) {
None => err!("You don't have permission to add item to organization"), None => err!("You don't have permission to add item to organization"),
Some(org_user) => { Some(org_user) => {
if shared_to_collection if shared_to_collection
|| org_user.has_full_access() || org_user.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) || cipher.is_write_accessible_to_user(&headers.user.uuid, conn)
{ {
cipher.organization_uuid = Some(org_id); cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
// TODO: Audit/Check the whole save/update cipher chain.
// Upstream uses the user_uuid to allow a cipher added by a user to an org to still allow the user to view/edit the cipher
// even when the user has hide-passwords configured as there policy.
// Removing the line below would fix that, but we have to check which effect this would have on the rest of the code.
cipher.user_uuid = None; cipher.user_uuid = None;
} else { } else {
err!("You don't have permission to add cipher directly to organization") err!("You don't have permission to add cipher directly to organization")
@@ -262,7 +365,7 @@ pub fn update_cipher_from_data(
// Modify attachments name and keys when rotating // Modify attachments name and keys when rotating
if let Some(attachments) = data.Attachments2 { if let Some(attachments) = data.Attachments2 {
for (id, attachment) in attachments { for (id, attachment) in attachments {
let mut saved_att = match Attachment::find_by_id(&id, &conn) { let mut saved_att = match Attachment::find_by_id(&id, conn) {
Some(att) => att, Some(att) => att,
None => err!("Attachment doesn't exist"), None => err!("Attachment doesn't exist"),
}; };
@@ -277,10 +380,24 @@ pub fn update_cipher_from_data(
saved_att.akey = Some(attachment.Key); saved_att.akey = Some(attachment.Key);
saved_att.file_name = attachment.FileName; saved_att.file_name = attachment.FileName;
saved_att.save(&conn)?; saved_att.save(conn)?;
} }
} }
// Cleanup cipher data, like removing the 'Response' key.
// This key is somewhere generated during Javascript so no way for us this fix this.
// Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key.
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
// But, we at least know we do not need to store and return this specific key.
fn _clean_cipher_data(mut json_data: Value) -> Value {
if json_data.is_array() {
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
f.as_object_mut().unwrap().remove("Response");
});
};
json_data
}
let type_data_opt = match data.Type { let type_data_opt = match data.Type {
1 => data.Login, 1 => data.Login,
2 => data.SecureNote, 2 => data.SecureNote,
@@ -289,32 +406,32 @@ pub fn update_cipher_from_data(
_ => err!("Invalid type"), _ => err!("Invalid type"),
}; };
let mut type_data = match type_data_opt { let type_data = match type_data_opt {
Some(data) => data, Some(mut data) => {
// Remove the 'Response' key from the base object.
data.as_object_mut().unwrap().remove("Response");
// Remove the 'Response' key from every Uri.
if data["Uris"].is_array() {
data["Uris"] = _clean_cipher_data(data["Uris"].clone());
}
data
}
None => err!("Data missing"), None => err!("Data missing"),
}; };
// TODO: ******* Backwards compat start **********
// To remove backwards compatibility, just delete this code,
// and remove the compat code from cipher::to_json
type_data["Name"] = Value::String(data.Name.clone());
type_data["Notes"] = data.Notes.clone().map(Value::String).unwrap_or(Value::Null);
type_data["Fields"] = data.Fields.clone().unwrap_or(Value::Null);
type_data["PasswordHistory"] = data.PasswordHistory.clone().unwrap_or(Value::Null);
// TODO: ******* Backwards compat end **********
cipher.name = data.Name; cipher.name = data.Name;
cipher.notes = data.Notes; cipher.notes = data.Notes;
cipher.fields = data.Fields.map(|f| f.to_string()); cipher.fields = data.Fields.map(|f| _clean_cipher_data(f).to_string());
cipher.data = type_data.to_string(); cipher.data = type_data.to_string();
cipher.password_history = data.PasswordHistory.map(|f| f.to_string()); cipher.password_history = data.PasswordHistory.map(|f| f.to_string());
cipher.reprompt = data.Reprompt;
cipher.save(&conn)?; cipher.save(conn)?;
cipher.move_to_folder(data.FolderId, &headers.user.uuid, &conn)?; cipher.move_to_folder(data.FolderId, &headers.user.uuid, conn)?;
cipher.set_favorite(data.Favorite, &headers.user.uuid, &conn)?; cipher.set_favorite(data.Favorite, &headers.user.uuid, conn)?;
if ut != UpdateType::None { if ut != UpdateType::None {
nt.send_cipher_update(ut, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn));
} }
Ok(()) Ok(())
@@ -374,6 +491,7 @@ fn post_ciphers_import(data: JsonUpcase<ImportData>, headers: Headers, conn: DbC
Ok(()) Ok(())
} }
/// Called when an org admin modifies an existing org cipher.
#[put("/ciphers/<uuid>/admin", data = "<data>")] #[put("/ciphers/<uuid>/admin", data = "<data>")]
fn put_cipher_admin( fn put_cipher_admin(
uuid: String, uuid: String,
@@ -479,14 +597,11 @@ fn post_collections_admin(
} }
let posted_collections: HashSet<String> = data.CollectionIds.iter().cloned().collect(); let posted_collections: HashSet<String> = data.CollectionIds.iter().cloned().collect();
let current_collections: HashSet<String> = cipher let current_collections: HashSet<String> =
.get_collections(&headers.user.uuid, &conn) cipher.get_collections(&headers.user.uuid, &conn).iter().cloned().collect();
.iter()
.cloned()
.collect();
for collection in posted_collections.symmetric_difference(&current_collections) { for collection in posted_collections.symmetric_difference(&current_collections) {
match Collection::find_by_uuid(&collection, &conn) { match Collection::find_by_uuid(collection, &conn) {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn) { if collection.is_writable_by_user(&headers.user.uuid, &conn) {
@@ -548,7 +663,7 @@ struct ShareSelectedCipherData {
} }
#[put("/ciphers/share", data = "<data>")] #[put("/ciphers/share", data = "<data>")]
fn put_cipher_share_seleted( fn put_cipher_share_selected(
data: JsonUpcase<ShareSelectedCipherData>, data: JsonUpcase<ShareSelectedCipherData>,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
@@ -600,9 +715,9 @@ fn share_cipher_by_uuid(
conn: &DbConn, conn: &DbConn,
nt: &Notify, nt: &Notify,
) -> JsonResult { ) -> JsonResult {
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) { let mut cipher = match Cipher::find_by_uuid(uuid, conn) {
Some(cipher) => { Some(cipher) => {
if cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) { if cipher.is_write_accessible_to_user(&headers.user.uuid, conn) {
cipher cipher
} else { } else {
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
@@ -619,11 +734,11 @@ fn share_cipher_by_uuid(
None => {} None => {}
Some(organization_uuid) => { Some(organization_uuid) => {
for uuid in &data.CollectionIds { for uuid in &data.CollectionIds {
match Collection::find_by_uuid_and_org(uuid, &organization_uuid, &conn) { match Collection::find_by_uuid_and_org(uuid, &organization_uuid, conn) {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn) { if collection.is_writable_by_user(&headers.user.uuid, conn) {
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn)?; CollectionCipher::save(&cipher.uuid, &collection.uuid, conn)?;
shared_to_collection = true; shared_to_collection = true;
} else { } else {
err!("No rights to modify the collection") err!("No rights to modify the collection")
@@ -637,43 +752,124 @@ fn share_cipher_by_uuid(
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
data.Cipher, data.Cipher,
&headers, headers,
shared_to_collection, shared_to_collection,
&conn, conn,
&nt, nt,
UpdateType::CipherUpdate, UpdateType::CipherUpdate,
)?; )?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn))) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, conn)))
} }
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")] /// v2 API for downloading an attachment. This just redirects the client to
fn post_attachment( /// the actual location of an attachment.
///
/// Upstream added this v2 API to support direct download of attachments from
/// their object storage service. For self-hosted instances, it basically just
/// redirects to the same location as before the v2 API.
#[get("/ciphers/<uuid>/attachment/<attachment_id>")]
fn get_attachment(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> JsonResult {
match Attachment::find_by_id(&attachment_id, &conn) {
Some(attachment) if uuid == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host))),
Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"),
}
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct AttachmentRequestData {
Key: String,
FileName: String,
FileSize: i32,
// We check org owner/admin status via is_write_accessible_to_user(),
// so we can just ignore this field.
//
// AdminRequest: bool,
}
enum FileUploadType {
Direct = 0,
// Azure = 1, // only used upstream
}
/// v2 API for creating an attachment associated with a cipher.
/// This redirects the client to the API it should use to upload the attachment.
/// For upstream's cloud-hosted service, it's an Azure object storage API.
/// For self-hosted instances, it's another API on the local instance.
#[post("/ciphers/<uuid>/attachment/v2", data = "<data>")]
fn post_attachment_v2(
uuid: String, uuid: String,
data: Data, data: JsonUpcase<AttachmentRequestData>,
content_type: &ContentType,
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify,
) -> JsonResult { ) -> JsonResult {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) { let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
};
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
err!("Cipher is not write accessible")
}
let attachment_id = crypto::generate_attachment_id();
let data: AttachmentRequestData = data.into_inner().data;
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.FileName, data.FileSize, Some(data.Key));
attachment.save(&conn).expect("Error saving attachment");
let url = format!("/ciphers/{}/attachment/{}", cipher.uuid, attachment_id);
Ok(Json(json!({ // AttachmentUploadDataResponseModel
"Object": "attachment-fileUpload",
"AttachmentId": attachment_id,
"Url": url,
"FileUploadType": FileUploadType::Direct as i32,
"CipherResponse": cipher.to_json(&headers.host, &headers.user.uuid, &conn),
"CipherMiniResponse": null,
})))
}
/// Saves the data content of an attachment to a file. This is common code
/// shared between the v2 and legacy attachment APIs.
///
/// When used with the legacy API, this function is responsible for creating
/// the attachment database record, so `attachment` is None.
///
/// When used with the v2 API, post_attachment_v2() has already created the
/// database record, which is passed in as `attachment`.
fn save_attachment(
mut attachment: Option<Attachment>,
cipher_uuid: String,
data: Data,
content_type: &ContentType,
headers: &Headers,
conn: &DbConn,
nt: Notify,
) -> Result<Cipher, crate::error::Error> {
let cipher = match Cipher::find_by_uuid(&cipher_uuid, conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err_discard!("Cipher doesn't exist", data), None => err_discard!("Cipher doesn't exist", data),
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) { if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn) {
err_discard!("Cipher is not write accessible", data) err_discard!("Cipher is not write accessible", data)
} }
let mut params = content_type.params(); // In the v2 API, the attachment record has already been created,
let boundary_pair = params.next().expect("No boundary provided"); // so the size limit needs to be adjusted to account for that.
let boundary = boundary_pair.1; let size_adjust = match &attachment {
None => 0, // Legacy API
Some(a) => a.file_size as i64, // v2 API
};
let size_limit = if let Some(ref user_uuid) = cipher.user_uuid { let size_limit = if let Some(ref user_uuid) = cipher.user_uuid {
match CONFIG.user_attachment_limit() { match CONFIG.user_attachment_limit() {
Some(0) => err_discard!("Attachments are disabled", data), Some(0) => err_discard!("Attachments are disabled", data),
Some(limit_kb) => { Some(limit_kb) => {
let left = (limit_kb * 1024) - Attachment::size_by_user(user_uuid, &conn); let left = (limit_kb * 1024) - Attachment::size_by_user(user_uuid, conn) + size_adjust;
if left <= 0 { if left <= 0 {
err_discard!("Attachment size limit reached! Delete some files to open space", data) err_discard!("Attachment size limit reached! Delete some files to open space", data)
} }
@@ -685,7 +881,7 @@ fn post_attachment(
match CONFIG.org_attachment_limit() { match CONFIG.org_attachment_limit() {
Some(0) => err_discard!("Attachments are disabled", data), Some(0) => err_discard!("Attachments are disabled", data),
Some(limit_kb) => { Some(limit_kb) => {
let left = (limit_kb * 1024) - Attachment::size_by_org(org_uuid, &conn); let left = (limit_kb * 1024) - Attachment::size_by_org(org_uuid, conn) + size_adjust;
if left <= 0 { if left <= 0 {
err_discard!("Attachment size limit reached! Delete some files to open space", data) err_discard!("Attachment size limit reached! Delete some files to open space", data)
} }
@@ -697,7 +893,12 @@ fn post_attachment(
err_discard!("Cipher is neither owned by a user nor an organization", data); err_discard!("Cipher is neither owned by a user nor an organization", data);
}; };
let base_path = Path::new(&CONFIG.attachments_folder()).join(&cipher.uuid); let mut params = content_type.params();
let boundary_pair = params.next().expect("No boundary provided");
let boundary = boundary_pair.1;
let base_path = Path::new(&CONFIG.attachments_folder()).join(&cipher_uuid);
let mut path = PathBuf::new();
let mut attachment_key = None; let mut attachment_key = None;
let mut error = None; let mut error = None;
@@ -713,34 +914,81 @@ fn post_attachment(
} }
} }
"data" => { "data" => {
// This is provided by the client, don't trust it // In the legacy API, this is the encrypted filename
let name = field.headers.filename.expect("No filename provided"); // provided by the client, stored to the database as-is.
// In the v2 API, this value doesn't matter, as it was
// already provided and stored via an earlier API call.
let encrypted_filename = field.headers.filename;
let file_name = HEXLOWER.encode(&crypto::get_random(vec![0; 10])); // This random ID is used as the name of the file on disk.
let path = base_path.join(&file_name); // In the legacy API, we need to generate this value here.
// In the v2 API, we use the value from post_attachment_v2().
let size = match field.data.save().memory_threshold(0).size_limit(size_limit).with_path(path.clone()) { let file_id = match &attachment {
SaveResult::Full(SavedData::File(_, size)) => size as i32, Some(attachment) => attachment.id.clone(), // v2 API
SaveResult::Full(other) => { None => crypto::generate_attachment_id(), // Legacy API
std::fs::remove_file(path).ok();
error = Some(format!("Attachment is not a file: {:?}", other));
return;
}
SaveResult::Partial(_, reason) => {
std::fs::remove_file(path).ok();
error = Some(format!("Attachment size limit exceeded with this file: {:?}", reason));
return;
}
SaveResult::Error(e) => {
std::fs::remove_file(path).ok();
error = Some(format!("Error: {:?}", e));
return;
}
}; };
path = base_path.join(&file_id);
let mut attachment = Attachment::new(file_name, cipher.uuid.clone(), name, size); let size =
attachment.akey = attachment_key.clone(); match field.data.save().memory_threshold(0).size_limit(size_limit).with_path(path.clone()) {
attachment.save(&conn).expect("Error saving attachment"); SaveResult::Full(SavedData::File(_, size)) => size as i32,
SaveResult::Full(other) => {
error = Some(format!("Attachment is not a file: {:?}", other));
return;
}
SaveResult::Partial(_, reason) => {
error = Some(format!("Attachment size limit exceeded with this file: {:?}", reason));
return;
}
SaveResult::Error(e) => {
error = Some(format!("Error: {:?}", e));
return;
}
};
if let Some(attachment) = &mut attachment {
// v2 API
// Check the actual size against the size initially provided by
// the client. Upstream allows +/- 1 MiB deviation from this
// size, but it's not clear when or why this is needed.
const LEEWAY: i32 = 1024 * 1024; // 1 MiB
let min_size = attachment.file_size - LEEWAY;
let max_size = attachment.file_size + LEEWAY;
if min_size <= size && size <= max_size {
if size != attachment.file_size {
// Update the attachment with the actual file size.
attachment.file_size = size;
attachment.save(conn).expect("Error updating attachment");
}
} else {
attachment.delete(conn).ok();
let err_msg = "Attachment size mismatch".to_string();
error!("{} (expected within [{}, {}], got {})", err_msg, min_size, max_size, size);
error = Some(err_msg);
}
} else {
// Legacy API
if encrypted_filename.is_none() {
error = Some("No filename provided".to_string());
return;
}
if attachment_key.is_none() {
error = Some("No attachment key provided".to_string());
return;
}
let attachment = Attachment::new(
file_id,
cipher_uuid.clone(),
encrypted_filename.unwrap(),
size,
attachment_key.clone(),
);
attachment.save(conn).expect("Error saving attachment");
}
} }
_ => error!("Invalid multipart name"), _ => error!("Invalid multipart name"),
} }
@@ -748,10 +996,55 @@ fn post_attachment(
.expect("Error processing multipart data"); .expect("Error processing multipart data");
if let Some(ref e) = error { if let Some(ref e) = error {
std::fs::remove_file(path).ok();
err!(e); err!(e);
} }
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn));
Ok(cipher)
}
/// v2 API for uploading the actual data content of an attachment.
/// This route needs a rank specified so that Rocket prioritizes the
/// /ciphers/<uuid>/attachment/v2 route, which would otherwise conflict
/// with this one.
#[post("/ciphers/<uuid>/attachment/<attachment_id>", format = "multipart/form-data", data = "<data>", rank = 1)]
fn post_attachment_v2_data(
uuid: String,
attachment_id: String,
data: Data,
content_type: &ContentType,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
let attachment = match Attachment::find_by_id(&attachment_id, &conn) {
Some(attachment) if uuid == attachment.cipher_uuid => Some(attachment),
Some(_) => err!("Attachment doesn't belong to cipher"),
None => err!("Attachment doesn't exist"),
};
save_attachment(attachment, uuid, data, content_type, &headers, &conn, nt)?;
Ok(())
}
/// Legacy API for creating an attachment associated with a cipher.
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
fn post_attachment(
uuid: String,
data: Data,
content_type: &ContentType,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> JsonResult {
// Setting this as None signifies to save_attachment() that it should create
// the attachment database record as well as saving the data to disk.
let attachment = None;
let cipher = save_attachment(attachment, uuid, data, content_type, &headers, &conn, nt)?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn))) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
} }
@@ -862,22 +1155,47 @@ fn delete_cipher_selected_post(data: JsonUpcase<Value>, headers: Headers, conn:
#[put("/ciphers/delete", data = "<data>")] #[put("/ciphers/delete", data = "<data>")]
fn delete_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn delete_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, true, nt) _delete_multiple_ciphers(data, headers, conn, true, nt) // soft delete
}
#[delete("/ciphers/admin", data = "<data>")]
fn delete_cipher_selected_admin(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
delete_cipher_selected(data, headers, conn, nt)
}
#[post("/ciphers/delete-admin", data = "<data>")]
fn delete_cipher_selected_post_admin(
data: JsonUpcase<Value>,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
delete_cipher_selected_post(data, headers, conn, nt)
}
#[put("/ciphers/delete-admin", data = "<data>")]
fn delete_cipher_selected_put_admin(
data: JsonUpcase<Value>,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
delete_cipher_selected_put(data, headers, conn, nt)
} }
#[put("/ciphers/<uuid>/restore")] #[put("/ciphers/<uuid>/restore")]
fn restore_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn restore_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt) _restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
} }
#[put("/ciphers/<uuid>/restore-admin")] #[put("/ciphers/<uuid>/restore-admin")]
fn restore_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn restore_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt) _restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
} }
#[put("/ciphers/restore", data = "<data>")] #[put("/ciphers/restore", data = "<data>")]
fn restore_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn restore_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
_restore_multiple_ciphers(data, headers, conn, nt) _restore_multiple_ciphers(data, &headers, &conn, &nt)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -963,7 +1281,6 @@ fn delete_all(
Some(user_org) => { Some(user_org) => {
if user_org.atype == UserOrgType::Owner { if user_org.atype == UserOrgType::Owner {
Cipher::delete_all_by_organization(&org_data.org_id, &conn)?; Cipher::delete_all_by_organization(&org_data.org_id, &conn)?;
Collection::delete_all_by_organization(&org_data.org_id, &conn)?;
nt.send_user_update(UpdateType::Vault, &user); nt.send_user_update(UpdateType::Vault, &user);
Ok(()) Ok(())
} else { } else {
@@ -992,28 +1309,34 @@ fn delete_all(
} }
fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, soft_delete: bool, nt: &Notify) -> EmptyResult { fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, soft_delete: bool, nt: &Notify) -> EmptyResult {
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) { let mut cipher = match Cipher::find_by_uuid(uuid, conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) { if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn) {
err!("Cipher can't be deleted by user") err!("Cipher can't be deleted by user")
} }
if soft_delete { if soft_delete {
cipher.deleted_at = Some(chrono::Utc::now().naive_utc()); cipher.deleted_at = Some(Utc::now().naive_utc());
cipher.save(&conn)?; cipher.save(conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn));
} else { } else {
cipher.delete(&conn)?; cipher.delete(conn)?;
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(conn));
} }
Ok(()) Ok(())
} }
fn _delete_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, soft_delete: bool, nt: Notify) -> EmptyResult { fn _delete_multiple_ciphers(
data: JsonUpcase<Value>,
headers: Headers,
conn: DbConn,
soft_delete: bool,
nt: Notify,
) -> EmptyResult {
let data: Value = data.into_inner().data; let data: Value = data.into_inner().data;
let uuids = match data.get("Ids") { let uuids = match data.get("Ids") {
@@ -1033,24 +1356,24 @@ fn _delete_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbC
Ok(()) Ok(())
} }
fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> EmptyResult { fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> JsonResult {
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) { let mut cipher = match Cipher::find_by_uuid(uuid, conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) { if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn) {
err!("Cipher can't be restored by user") err!("Cipher can't be restored by user")
} }
cipher.deleted_at = None; cipher.deleted_at = None;
cipher.save(&conn)?; cipher.save(conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn));
Ok(()) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, conn)))
} }
fn _restore_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn _restore_multiple_ciphers(data: JsonUpcase<Value>, headers: &Headers, conn: &DbConn, nt: &Notify) -> JsonResult {
let data: Value = data.into_inner().data; let data: Value = data.into_inner().data;
let uuids = match data.get("Ids") { let uuids = match data.get("Ids") {
@@ -1061,13 +1384,19 @@ fn _restore_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: Db
None => err!("Request missing ids field"), None => err!("Request missing ids field"),
}; };
let mut ciphers: Vec<Value> = Vec::new();
for uuid in uuids { for uuid in uuids {
if let error @ Err(_) = _restore_cipher_by_uuid(uuid, &headers, &conn, &nt) { match _restore_cipher_by_uuid(uuid, headers, conn, nt) {
return error; Ok(json) => ciphers.push(json.into_inner()),
}; err => return err,
}
} }
Ok(()) Ok(Json(json!({
"Data": ciphers,
"Object": "list",
"ContinuationToken": null
})))
} }
fn _delete_cipher_attachment_by_id( fn _delete_cipher_attachment_by_id(
@@ -1077,7 +1406,7 @@ fn _delete_cipher_attachment_by_id(
conn: &DbConn, conn: &DbConn,
nt: &Notify, nt: &Notify,
) -> EmptyResult { ) -> EmptyResult {
let attachment = match Attachment::find_by_id(&attachment_id, &conn) { let attachment = match Attachment::find_by_id(attachment_id, conn) {
Some(attachment) => attachment, Some(attachment) => attachment,
None => err!("Attachment doesn't exist"), None => err!("Attachment doesn't exist"),
}; };
@@ -1086,17 +1415,17 @@ fn _delete_cipher_attachment_by_id(
err!("Attachment from other cipher") err!("Attachment from other cipher")
} }
let cipher = match Cipher::find_by_uuid(&uuid, &conn) { let cipher = match Cipher::find_by_uuid(uuid, conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) { if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn) {
err!("Cipher cannot be deleted by user") err!("Cipher cannot be deleted by user")
} }
// Delete attachment // Delete attachment
attachment.delete(&conn)?; attachment.delete(conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn));
Ok(()) Ok(())
} }

View File

@@ -8,28 +8,20 @@ use crate::{
}; };
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
routes![ routes![get_folders, get_folder, post_folders, post_folder, put_folder, delete_folder_post, delete_folder,]
get_folders,
get_folder,
post_folders,
post_folder,
put_folder,
delete_folder_post,
delete_folder,
]
} }
#[get("/folders")] #[get("/folders")]
fn get_folders(headers: Headers, conn: DbConn) -> JsonResult { fn get_folders(headers: Headers, conn: DbConn) -> Json<Value> {
let folders = Folder::find_by_user(&headers.user.uuid, &conn); let folders = Folder::find_by_user(&headers.user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect(); let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect();
Ok(Json(json!({ Json(json!({
"Data": folders_json, "Data": folders_json,
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[get("/folders/<uuid>")] #[get("/folders/<uuid>")]

View File

@@ -2,17 +2,15 @@ mod accounts;
mod ciphers; mod ciphers;
mod folders; mod folders;
mod organizations; mod organizations;
mod sends;
pub mod two_factor; pub mod two_factor;
pub use ciphers::purge_trashed_ciphers;
pub use sends::purge_sends;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut mod_routes = routes![ let mut mod_routes =
clear_device_token, routes![clear_device_token, put_device_token, get_eq_domains, post_eq_domains, put_eq_domains, hibp_breach,];
put_device_token,
get_eq_domains,
post_eq_domains,
put_eq_domains,
hibp_breach,
];
let mut routes = Vec::new(); let mut routes = Vec::new();
routes.append(&mut accounts::routes()); routes.append(&mut accounts::routes());
@@ -20,6 +18,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut folders::routes()); routes.append(&mut folders::routes());
routes.append(&mut organizations::routes()); routes.append(&mut organizations::routes());
routes.append(&mut two_factor::routes()); routes.append(&mut two_factor::routes());
routes.append(&mut sends::routes());
routes.append(&mut mod_routes); routes.append(&mut mod_routes);
routes routes
@@ -33,14 +32,15 @@ use rocket_contrib::json::Json;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::{EmptyResult, JsonResult, JsonUpcase}, api::{JsonResult, JsonUpcase},
auth::Headers, auth::Headers,
db::DbConn, db::DbConn,
error::Error, error::Error,
util::get_reqwest_client,
}; };
#[put("/devices/identifier/<uuid>/clear-token")] #[put("/devices/identifier/<uuid>/clear-token")]
fn clear_device_token(uuid: String) -> EmptyResult { fn clear_device_token(uuid: String) -> &'static str {
// This endpoint doesn't have auth header // This endpoint doesn't have auth header
let _ = uuid; let _ = uuid;
@@ -49,11 +49,11 @@ fn clear_device_token(uuid: String) -> EmptyResult {
// This only clears push token // This only clears push token
// https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109 // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109
// https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37 // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37
Ok(()) ""
} }
#[put("/devices/identifier/<uuid>/token", data = "<data>")] #[put("/devices/identifier/<uuid>/token", data = "<data>")]
fn put_device_token(uuid: String, data: JsonUpcase<Value>, headers: Headers) -> JsonResult { fn put_device_token(uuid: String, data: JsonUpcase<Value>, headers: Headers) -> Json<Value> {
let _data: Value = data.into_inner().data; let _data: Value = data.into_inner().data;
// Data has a single string value "PushToken" // Data has a single string value "PushToken"
let _ = uuid; let _ = uuid;
@@ -61,13 +61,13 @@ fn put_device_token(uuid: String, data: JsonUpcase<Value>, headers: Headers) ->
// TODO: This should save the push token, but we don't have push functionality // TODO: This should save the push token, but we don't have push functionality
Ok(Json(json!({ Json(json!({
"Id": headers.device.uuid, "Id": headers.device.uuid,
"Name": headers.device.name, "Name": headers.device.name,
"Type": headers.device.atype, "Type": headers.device.atype,
"Identifier": headers.device.uuid, "Identifier": headers.device.uuid,
"CreationDate": crate::util::format_date(&headers.device.created_at), "CreationDate": crate::util::format_date(&headers.device.created_at),
}))) }))
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@@ -81,11 +81,11 @@ struct GlobalDomain {
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
#[get("/settings/domains")] #[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> JsonResult { fn get_eq_domains(headers: Headers) -> Json<Value> {
_get_eq_domains(headers, false) _get_eq_domains(headers, false)
} }
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> JsonResult { fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> {
let user = headers.user; let user = headers.user;
use serde_json::from_str; use serde_json::from_str;
@@ -102,11 +102,11 @@ fn _get_eq_domains(headers: Headers, no_excluded: bool) -> JsonResult {
globals.retain(|g| !g.Excluded); globals.retain(|g| !g.Excluded);
} }
Ok(Json(json!({ Json(json!({
"EquivalentDomains": equivalent_domains, "EquivalentDomains": equivalent_domains,
"GlobalEquivalentDomains": globals, "GlobalEquivalentDomains": globals,
"Object": "domains", "Object": "domains",
}))) }))
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -141,22 +141,15 @@ fn put_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbC
#[get("/hibp/breach?<username>")] #[get("/hibp/breach?<username>")]
fn hibp_breach(username: String) -> JsonResult { fn hibp_breach(username: String) -> JsonResult {
let user_agent = "Bitwarden_RS";
let url = format!( let url = format!(
"https://haveibeenpwned.com/api/v3/breachedaccount/{}?truncateResponse=false&includeUnverified=false", "https://haveibeenpwned.com/api/v3/breachedaccount/{}?truncateResponse=false&includeUnverified=false",
username username
); );
use reqwest::{blocking::Client, header::USER_AGENT};
if let Some(api_key) = crate::CONFIG.hibp_api_key() { if let Some(api_key) = crate::CONFIG.hibp_api_key() {
let hibp_client = Client::builder().build()?; let hibp_client = get_reqwest_client();
let res = hibp_client let res = hibp_client.get(&url).header("hibp-api-key", api_key).send()?;
.get(&url)
.header(USER_AGENT, user_agent)
.header("hibp-api-key", api_key)
.send()?;
// If we get a 404, return a 404, it means no breached accounts // If we get a 404, return a 404, it means no breached accounts
if res.status() == 404 { if res.status() == 404 {
@@ -172,7 +165,7 @@ fn hibp_breach(username: String) -> JsonResult {
"Domain": "haveibeenpwned.com", "Domain": "haveibeenpwned.com",
"BreachDate": "2019-08-18T00:00:00Z", "BreachDate": "2019-08-18T00:00:00Z",
"AddedDate": "2019-08-18T00:00:00Z", "AddedDate": "2019-08-18T00:00:00Z",
"Description": format!("Go to: <a href=\"https://haveibeenpwned.com/account/{account}\" target=\"_blank\" rel=\"noopener\">https://haveibeenpwned.com/account/{account}</a> for a manual check.<br/><br/>HaveIBeenPwned API key not set!<br/>Go to <a href=\"https://haveibeenpwned.com/API/Key\" target=\"_blank\" rel=\"noopener\">https://haveibeenpwned.com/API/Key</a> to purchase an API key from HaveIBeenPwned.<br/><br/>", account=username), "Description": format!("Go to: <a href=\"https://haveibeenpwned.com/account/{account}\" target=\"_blank\" rel=\"noreferrer\">https://haveibeenpwned.com/account/{account}</a> for a manual check.<br/><br/>HaveIBeenPwned API key not set!<br/>Go to <a href=\"https://haveibeenpwned.com/API/Key\" target=\"_blank\" rel=\"noreferrer\">https://haveibeenpwned.com/API/Key</a> to purchase an API key from HaveIBeenPwned.<br/><br/>", account=username),
"LogoPath": "bwrs_static/hibp.png", "LogoPath": "bwrs_static/hibp.png",
"PwnCount": 0, "PwnCount": 0,
"DataClasses": [ "DataClasses": [

View File

@@ -5,7 +5,7 @@ use serde_json::Value;
use crate::{ use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType}, api::{EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType},
auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders}, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
db::{models::*, DbConn}, db::{models::*, DbConn},
mail, CONFIG, mail, CONFIG,
}; };
@@ -47,7 +47,10 @@ pub fn routes() -> Vec<Route> {
list_policies_token, list_policies_token,
get_policy, get_policy,
put_policy, put_policy,
get_organization_tax,
get_plans, get_plans,
get_plans_tax_rates,
import,
] ]
} }
@@ -189,8 +192,8 @@ fn post_organization(
// GET /api/collections?writeOnly=false // GET /api/collections?writeOnly=false
#[get("/collections")] #[get("/collections")]
fn get_user_collections(headers: Headers, conn: DbConn) -> JsonResult { fn get_user_collections(headers: Headers, conn: DbConn) -> Json<Value> {
Ok(Json(json!({ Json(json!({
"Data": "Data":
Collection::find_by_user_uuid(&headers.user.uuid, &conn) Collection::find_by_user_uuid(&headers.user.uuid, &conn)
.iter() .iter()
@@ -198,12 +201,12 @@ fn get_user_collections(headers: Headers, conn: DbConn) -> JsonResult {
.collect::<Value>(), .collect::<Value>(),
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[get("/organizations/<org_id>/collections")] #[get("/organizations/<org_id>/collections")]
fn get_org_collections(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn get_org_collections(org_id: String, _headers: AdminHeaders, conn: DbConn) -> Json<Value> {
Ok(Json(json!({ Json(json!({
"Data": "Data":
Collection::find_by_organization(&org_id, &conn) Collection::find_by_organization(&org_id, &conn)
.iter() .iter()
@@ -211,13 +214,13 @@ fn get_org_collections(org_id: String, _headers: AdminHeaders, conn: DbConn) ->
.collect::<Value>(), .collect::<Value>(),
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[post("/organizations/<org_id>/collections", data = "<data>")] #[post("/organizations/<org_id>/collections", data = "<data>")]
fn post_organization_collections( fn post_organization_collections(
org_id: String, org_id: String,
_headers: AdminHeaders, headers: ManagerHeadersLoose,
data: JsonUpcase<NewCollectionData>, data: JsonUpcase<NewCollectionData>,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
@@ -228,9 +231,22 @@ fn post_organization_collections(
None => err!("Can't find organization details"), None => err!("Can't find organization details"),
}; };
// Get the user_organization record so that we can check if the user has access to all collections.
let user_org = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
Some(u) => u,
None => err!("User is not part of organization"),
};
let collection = Collection::new(org.uuid, data.Name); let collection = Collection::new(org.uuid, data.Name);
collection.save(&conn)?; collection.save(&conn)?;
// If the user doesn't have access to all collections, only in case of a Manger,
// then we need to save the creating user uuid (Manager) to the users_collection table.
// Else the user will not have access to his own created collection.
if !user_org.access_all {
CollectionUser::save(&headers.user.uuid, &collection.uuid, false, false, &conn)?;
}
Ok(Json(collection.to_json())) Ok(Json(collection.to_json()))
} }
@@ -238,7 +254,7 @@ fn post_organization_collections(
fn put_organization_collection_update( fn put_organization_collection_update(
org_id: String, org_id: String,
col_id: String, col_id: String,
headers: AdminHeaders, headers: ManagerHeaders,
data: JsonUpcase<NewCollectionData>, data: JsonUpcase<NewCollectionData>,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
@@ -249,7 +265,7 @@ fn put_organization_collection_update(
fn post_organization_collection_update( fn post_organization_collection_update(
org_id: String, org_id: String,
col_id: String, col_id: String,
_headers: AdminHeaders, _headers: ManagerHeaders,
data: JsonUpcase<NewCollectionData>, data: JsonUpcase<NewCollectionData>,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
@@ -317,7 +333,12 @@ fn post_organization_collection_delete_user(
} }
#[delete("/organizations/<org_id>/collections/<col_id>")] #[delete("/organizations/<org_id>/collections/<col_id>")]
fn delete_organization_collection(org_id: String, col_id: String, _headers: AdminHeaders, conn: DbConn) -> EmptyResult { fn delete_organization_collection(
org_id: String,
col_id: String,
_headers: ManagerHeaders,
conn: DbConn,
) -> EmptyResult {
match Collection::find_by_uuid(&col_id, &conn) { match Collection::find_by_uuid(&col_id, &conn) {
None => err!("Collection not found"), None => err!("Collection not found"),
Some(collection) => { Some(collection) => {
@@ -341,7 +362,7 @@ struct DeleteCollectionData {
fn post_organization_collection_delete( fn post_organization_collection_delete(
org_id: String, org_id: String,
col_id: String, col_id: String,
headers: AdminHeaders, headers: ManagerHeaders,
_data: JsonUpcase<DeleteCollectionData>, _data: JsonUpcase<DeleteCollectionData>,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
@@ -349,7 +370,7 @@ fn post_organization_collection_delete(
} }
#[get("/organizations/<org_id>/collections/<coll_id>/details")] #[get("/organizations/<org_id>/collections/<coll_id>/details")]
fn get_org_collection_detail(org_id: String, coll_id: String, headers: AdminHeaders, conn: DbConn) -> JsonResult { fn get_org_collection_detail(org_id: String, coll_id: String, headers: ManagerHeaders, conn: DbConn) -> JsonResult {
match Collection::find_by_uuid_and_user(&coll_id, &headers.user.uuid, &conn) { match Collection::find_by_uuid_and_user(&coll_id, &headers.user.uuid, &conn) {
None => err!("Collection not found"), None => err!("Collection not found"),
Some(collection) => { Some(collection) => {
@@ -363,7 +384,7 @@ fn get_org_collection_detail(org_id: String, coll_id: String, headers: AdminHead
} }
#[get("/organizations/<org_id>/collections/<coll_id>/users")] #[get("/organizations/<org_id>/collections/<coll_id>/users")]
fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn get_collection_users(org_id: String, coll_id: String, _headers: ManagerHeaders, conn: DbConn) -> JsonResult {
// Get org and collection, check that collection is from org // Get org and collection, check that collection is from org
let collection = match Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn) { let collection = match Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
@@ -376,7 +397,7 @@ fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders,
.map(|col_user| { .map(|col_user| {
UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn)
.unwrap() .unwrap()
.to_json_user_access_restrictions(&col_user) .to_json_user_access_restrictions(col_user)
}) })
.collect(); .collect();
@@ -388,7 +409,7 @@ fn put_collection_users(
org_id: String, org_id: String,
coll_id: String, coll_id: String,
data: JsonUpcaseVec<CollectionData>, data: JsonUpcaseVec<CollectionData>,
_headers: AdminHeaders, _headers: ManagerHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
// Get org and collection, check that collection is from org // Get org and collection, check that collection is from org
@@ -410,9 +431,7 @@ fn put_collection_users(
continue; continue;
} }
CollectionUser::save(&user.user_uuid, &coll_id, CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, d.HidePasswords, &conn)?;
d.ReadOnly, d.HidePasswords,
&conn)?;
} }
Ok(()) Ok(())
@@ -425,30 +444,28 @@ struct OrgIdData {
} }
#[get("/ciphers/organization-details?<data..>")] #[get("/ciphers/organization-details?<data..>")]
fn get_org_details(data: Form<OrgIdData>, headers: Headers, conn: DbConn) -> JsonResult { fn get_org_details(data: Form<OrgIdData>, headers: Headers, conn: DbConn) -> Json<Value> {
let ciphers = Cipher::find_by_org(&data.organization_id, &conn); let ciphers = Cipher::find_by_org(&data.organization_id, &conn);
let ciphers_json: Vec<Value> = ciphers let ciphers_json: Vec<Value> =
.iter() ciphers.iter().map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn)).collect();
.map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn))
.collect();
Ok(Json(json!({ Json(json!({
"Data": ciphers_json, "Data": ciphers_json,
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[get("/organizations/<org_id>/users")] #[get("/organizations/<org_id>/users")]
fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn get_org_users(org_id: String, _headers: ManagerHeadersLoose, conn: DbConn) -> Json<Value> {
let users = UserOrganization::find_by_org(&org_id, &conn); let users = UserOrganization::find_by_org(&org_id, &conn);
let users_json: Vec<Value> = users.iter().map(|c| c.to_json_user_details(&conn)).collect(); let users_json: Vec<Value> = users.iter().map(|c| c.to_json_user_details(&conn)).collect();
Ok(Json(json!({ Json(json!({
"Data": users_json, "Data": users_json,
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -487,13 +504,13 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
} else { } else {
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
}; };
let user = match User::find_by_mail(&email, &conn) { let user = match User::find_by_mail(email, &conn) {
None => { None => {
if !CONFIG.invitations_allowed() { if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {}", email)) err!(format!("User does not exist: {}", email))
} }
if !CONFIG.is_email_domain_allowed(&email) { if !CONFIG.is_email_domain_allowed(email) {
err!("Email domain not eligible for invitations") err!("Email domain not eligible for invitations")
} }
@@ -528,9 +545,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {
CollectionUser::save(&user.uuid, &collection.uuid, CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, col.HidePasswords, &conn)?;
col.ReadOnly, col.HidePasswords,
&conn)?;
} }
} }
} }
@@ -545,7 +560,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
}; };
mail::send_invite( mail::send_invite(
&email, email,
&user.uuid, &user.uuid,
Some(org_id.clone()), Some(org_id.clone()),
Some(new_user.uuid), Some(new_user.uuid),
@@ -615,7 +630,7 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
// The web-vault passes org_id and org_user_id in the URL, but we are just reading them from the JWT instead // The web-vault passes org_id and org_user_id in the URL, but we are just reading them from the JWT instead
let data: AcceptData = data.into_inner().data; let data: AcceptData = data.into_inner().data;
let token = &data.Token; let token = &data.Token;
let claims = decode_invite(&token)?; let claims = decode_invite(token)?;
match User::find_by_mail(&claims.email, &conn) { match User::find_by_mail(&claims.email, &conn) {
Some(_) => { Some(_) => {
@@ -639,9 +654,9 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
} }
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let mut org_name = String::from("bitwarden_rs"); let mut org_name = CONFIG.invitation_org_name();
if let Some(org_id) = &claims.org_id { if let Some(org_id) = &claims.org_id {
org_name = match Organization::find_by_uuid(&org_id, &conn) { org_name = match Organization::find_by_uuid(org_id, &conn) {
Some(org) => org.name, Some(org) => org.name,
None => err!("Organization not found."), None => err!("Organization not found."),
}; };
@@ -785,9 +800,13 @@ fn edit_user(
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {
CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, CollectionUser::save(
col.ReadOnly, col.HidePasswords, &user_to_edit.user_uuid,
&conn)?; &collection.uuid,
col.ReadOnly,
col.HidePasswords,
&conn,
)?;
} }
} }
} }
@@ -883,16 +902,8 @@ fn post_org_import(
.into_iter() .into_iter()
.map(|cipher_data| { .map(|cipher_data| {
let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone()); let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone());
update_cipher_from_data( update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::CipherCreate)
&mut cipher, .ok();
cipher_data,
&headers,
false,
&conn,
&nt,
UpdateType::CipherCreate,
)
.ok();
cipher cipher
}) })
.collect(); .collect();
@@ -914,15 +925,15 @@ fn post_org_import(
} }
#[get("/organizations/<org_id>/policies")] #[get("/organizations/<org_id>/policies")]
fn list_policies(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn list_policies(org_id: String, _headers: AdminHeaders, conn: DbConn) -> Json<Value> {
let policies = OrgPolicy::find_by_org(&org_id, &conn); let policies = OrgPolicy::find_by_org(&org_id, &conn);
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect(); let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
Ok(Json(json!({ Json(json!({
"Data": policies_json, "Data": policies_json,
"Object": "list", "Object": "list",
"ContinuationToken": null "ContinuationToken": null
}))) }))
} }
#[get("/organizations/<org_id>/policies/token?<token>")] #[get("/organizations/<org_id>/policies/token?<token>")]
@@ -953,7 +964,7 @@ fn list_policies_token(org_id: String, token: String, conn: DbConn) -> JsonResul
fn get_policy(org_id: String, pol_type: i32, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn get_policy(org_id: String, pol_type: i32, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
Some(pt) => pt, Some(pt) => pt,
None => err!("Invalid policy type"), None => err!("Invalid or unsupported policy type"),
}; };
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type, &conn) { let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type, &conn) {
@@ -973,7 +984,13 @@ struct PolicyData {
} }
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")] #[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
fn put_policy(org_id: String, pol_type: i32, data: Json<PolicyData>, _headers: AdminHeaders, conn: DbConn) -> JsonResult { fn put_policy(
org_id: String,
pol_type: i32,
data: Json<PolicyData>,
_headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
let data: PolicyData = data.into_inner(); let data: PolicyData = data.into_inner();
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
@@ -993,9 +1010,16 @@ fn put_policy(org_id: String, pol_type: i32, data: Json<PolicyData>, _headers: A
Ok(Json(policy.to_json())) Ok(Json(policy.to_json()))
} }
#[allow(unused_variables)]
#[get("/organizations/<org_id>/tax")]
fn get_organization_tax(org_id: String, _headers: Headers, _conn: DbConn) -> EmptyResult {
// Prevent a 404 error, which also causes Javascript errors.
err!("Only allowed when not self hosted.")
}
#[get("/plans")] #[get("/plans")]
fn get_plans(_headers: Headers, _conn: DbConn) -> JsonResult { fn get_plans(_headers: Headers, _conn: DbConn) -> Json<Value> {
Ok(Json(json!({ Json(json!({
"Object": "list", "Object": "list",
"Data": [ "Data": [
{ {
@@ -1042,5 +1066,111 @@ fn get_plans(_headers: Headers, _conn: DbConn) -> JsonResult {
} }
], ],
"ContinuationToken": null "ContinuationToken": null
}))) }))
} }
#[get("/plans/sales-tax-rates")]
fn get_plans_tax_rates(_headers: Headers, _conn: DbConn) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(json!({
"Object": "list",
"Data": [],
"ContinuationToken": null
}))
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct OrgImportGroupData {
Name: String, // "GroupName"
ExternalId: String, // "cn=GroupName,ou=Groups,dc=example,dc=com"
Users: Vec<String>, // ["uid=user,ou=People,dc=example,dc=com"]
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct OrgImportUserData {
Email: String, // "user@maildomain.net"
ExternalId: String, // "uid=user,ou=People,dc=example,dc=com"
Deleted: bool,
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct OrgImportData {
Groups: Vec<OrgImportGroupData>,
OverwriteExisting: bool,
Users: Vec<OrgImportUserData>,
}
#[post("/organizations/<org_id>/import", data = "<data>")]
fn import(org_id: String, data: JsonUpcase<OrgImportData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data = data.into_inner().data;
// TODO: Currently we aren't storing the externalId's anywhere, so we also don't have a way
// to differentiate between auto-imported users and manually added ones.
// This means that this endpoint can end up removing users that were added manually by an admin,
// as opposed to upstream which only removes auto-imported users.
// User needs to be admin or owner to use the Directry Connector
match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
Some(user_org) if user_org.atype >= UserOrgType::Admin => { /* Okay, nothing to do */ }
Some(_) => err!("User has insufficient permissions to use Directory Connector"),
None => err!("User not part of organization"),
};
for user_data in &data.Users {
if user_data.Deleted {
// If user is marked for deletion and it exists, delete it
if let Some(user_org) = UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &conn) {
user_org.delete(&conn)?;
}
// If user is not part of the organization, but it exists
} else if UserOrganization::find_by_email_and_org(&user_data.Email, &org_id, &conn).is_none() {
if let Some(user) = User::find_by_mail(&user_data.Email, &conn) {
let user_org_status = if CONFIG.mail_enabled() {
UserOrgStatus::Invited as i32
} else {
UserOrgStatus::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.access_all = false;
new_org_user.atype = UserOrgType::User as i32;
new_org_user.status = user_org_status;
new_org_user.save(&conn)?;
if CONFIG.mail_enabled() {
let org_name = match Organization::find_by_uuid(&org_id, &conn) {
Some(org) => org.name,
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(headers.user.email.clone()),
)?;
}
}
}
}
// If this flag is enabled, any user that isn't provided in the Users list will be removed (by default they will be kept unless they have Deleted == true)
if data.OverwriteExisting {
for user_org in UserOrganization::find_by_org_and_type(&org_id, UserOrgType::User as i32, &conn) {
if let Some(user_email) = User::find_by_uuid(&user_org.user_uuid, &conn).map(|u| u.email) {
if !data.Users.iter().any(|u| u.Email == user_email) {
user_org.delete(&conn)?;
}
}
}
}
Ok(())
}

439
src/api/core/sends.rs Normal file
View File

@@ -0,0 +1,439 @@
use std::{io::Read, path::Path};
use chrono::{DateTime, Duration, Utc};
use multipart::server::{save::SavedData, Multipart, SaveResult};
use rocket::{http::ContentType, response::NamedFile, Data};
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::{
api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType},
auth::{Headers, Host},
db::{models::*, DbConn, DbPool},
CONFIG,
};
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
pub fn routes() -> Vec<rocket::Route> {
routes![
post_send,
post_send_file,
post_access,
post_access_file,
put_send,
delete_send,
put_remove_password,
download_send
]
}
pub fn purge_sends(pool: DbPool) {
debug!("Purging sends");
if let Ok(conn) = pool.get() {
Send::purge(&conn);
} else {
error!("Failed to get DB connection while purging sends")
}
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
pub struct SendData {
pub Type: i32,
pub Key: String,
pub Password: Option<String>,
pub MaxAccessCount: Option<i32>,
pub ExpirationDate: Option<DateTime<Utc>>,
pub DeletionDate: DateTime<Utc>,
pub Disabled: bool,
pub HideEmail: Option<bool>,
// Data field
pub Name: String,
pub Notes: Option<String>,
pub Text: Option<Value>,
pub File: Option<Value>,
}
/// Enforces the `Disable Send` policy. A non-owner/admin user belonging to
/// an org with this policy enabled isn't allowed to create new Sends or
/// modify existing ones, but is allowed to delete them.
///
/// Ref: https://bitwarden.com/help/article/policies/#disable-send
///
/// There is also a Vaultwarden-specific `sends_allowed` config setting that
/// controls this policy globally.
fn enforce_disable_send_policy(headers: &Headers, conn: &DbConn) -> EmptyResult {
let user_uuid = &headers.user.uuid;
let policy_type = OrgPolicyType::DisableSend;
if !CONFIG.sends_allowed() || OrgPolicy::is_applicable_to_user(user_uuid, policy_type, conn) {
err!("Due to an Enterprise Policy, you are only able to delete an existing Send.")
}
Ok(())
}
/// Enforces the `DisableHideEmail` option of the `Send Options` policy.
/// A non-owner/admin user belonging to an org with this option enabled isn't
/// allowed to hide their email address from the recipient of a Bitwarden Send,
/// but is allowed to remove this option from an existing Send.
///
/// Ref: https://bitwarden.com/help/article/policies/#send-options
fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &DbConn) -> EmptyResult {
let user_uuid = &headers.user.uuid;
let hide_email = data.HideEmail.unwrap_or(false);
if hide_email && OrgPolicy::is_hide_email_disabled(user_uuid, conn) {
err!(
"Due to an Enterprise Policy, you are not allowed to hide your email address \
from recipients when creating or editing a Send."
)
}
Ok(())
}
fn create_send(data: SendData, user_uuid: String) -> ApiResult<Send> {
let data_val = if data.Type == SendType::Text as i32 {
data.Text
} else if data.Type == SendType::File as i32 {
data.File
} else {
err!("Invalid Send type")
};
let data_str = if let Some(mut d) = data_val {
d.as_object_mut().and_then(|o| o.remove("Response"));
serde_json::to_string(&d)?
} else {
err!("Send data not provided");
};
if data.DeletionDate > Utc::now() + Duration::days(31) {
err!(
"You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again."
);
}
let mut send = Send::new(data.Type, data.Name, data_str, data.Key, data.DeletionDate.naive_utc());
send.user_uuid = Some(user_uuid);
send.notes = data.Notes;
send.max_access_count = data.MaxAccessCount;
send.expiration_date = data.ExpirationDate.map(|d| d.naive_utc());
send.disabled = data.Disabled;
send.hide_email = data.HideEmail;
send.atype = data.Type;
send.set_password(data.Password.as_deref());
Ok(send)
}
#[post("/sends", data = "<data>")]
fn post_send(data: JsonUpcase<SendData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
enforce_disable_send_policy(&headers, &conn)?;
let data: SendData = data.into_inner().data;
enforce_disable_hide_email_policy(&data, &headers, &conn)?;
if data.Type == SendType::File as i32 {
err!("File sends should use /api/sends/file")
}
let mut send = create_send(data, headers.user.uuid.clone())?;
send.save(&conn)?;
nt.send_user_update(UpdateType::SyncSendCreate, &headers.user);
Ok(Json(send.to_json()))
}
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
fn post_send_file(data: Data, content_type: &ContentType, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
enforce_disable_send_policy(&headers, &conn)?;
let boundary = content_type.params().next().expect("No boundary provided").1;
let mut mpart = Multipart::with_body(data.open(), boundary);
// First entry is the SendData JSON
let mut model_entry = match mpart.read_entry()? {
Some(e) if &*e.headers.name == "model" => e,
Some(_) => err!("Invalid entry name"),
None => err!("No model entry present"),
};
let mut buf = String::new();
model_entry.data.read_to_string(&mut buf)?;
let data = serde_json::from_str::<crate::util::UpCase<SendData>>(&buf)?;
enforce_disable_hide_email_policy(&data.data, &headers, &conn)?;
// Get the file length and add an extra 10% to avoid issues
const SIZE_110_MB: u64 = 115_343_360;
let size_limit = match CONFIG.user_attachment_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let left = (limit_kb * 1024) - Attachment::size_by_user(&headers.user.uuid, &conn);
if left <= 0 {
err!("Attachment size limit reached! Delete some files to open space")
}
std::cmp::Ord::max(left as u64, SIZE_110_MB)
}
None => SIZE_110_MB,
};
// Create the Send
let mut send = create_send(data.data, headers.user.uuid.clone())?;
let file_id = crate::crypto::generate_send_id();
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_path = Path::new(&CONFIG.sends_folder()).join(&send.uuid).join(&file_id);
// Read the data entry and save the file
let mut data_entry = match mpart.read_entry()? {
Some(e) if &*e.headers.name == "data" => e,
Some(_) => err!("Invalid entry name"),
None => err!("No model entry present"),
};
let size = match data_entry.data.save().memory_threshold(0).size_limit(size_limit).with_path(&file_path) {
SaveResult::Full(SavedData::File(_, size)) => size as i32,
SaveResult::Full(other) => {
std::fs::remove_file(&file_path).ok();
err!(format!("Attachment is not a file: {:?}", other));
}
SaveResult::Partial(_, reason) => {
std::fs::remove_file(&file_path).ok();
err!(format!("Attachment size limit exceeded with this file: {:?}", reason));
}
SaveResult::Error(e) => {
std::fs::remove_file(&file_path).ok();
err!(format!("Error: {:?}", e));
}
};
// Set ID and sizes
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("Id"), Value::String(file_id));
o.insert(String::from("Size"), Value::Number(size.into()));
o.insert(String::from("SizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn)?;
nt.send_user_update(UpdateType::SyncSendCreate, &headers.user);
Ok(Json(send.to_json()))
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
pub struct SendAccessData {
pub Password: Option<String>,
}
#[post("/sends/access/<access_id>", data = "<data>")]
fn post_access(access_id: String, data: JsonUpcase<SendAccessData>, conn: DbConn) -> JsonResult {
let mut send = match Send::find_by_access_id(&access_id, &conn) {
Some(s) => s,
None => err_code!(SEND_INACCESSIBLE_MSG, 404),
};
if let Some(max_access_count) = send.max_access_count {
if send.access_count >= max_access_count {
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
}
if let Some(expiration) = send.expiration_date {
if Utc::now().naive_utc() >= expiration {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().data.Password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
}
// Files are incremented during the download
if send.atype == SendType::Text as i32 {
send.access_count += 1;
}
send.save(&conn)?;
Ok(Json(send.to_json_access(&conn)))
}
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
fn post_access_file(
send_id: String,
file_id: String,
data: JsonUpcase<SendAccessData>,
host: Host,
conn: DbConn,
) -> JsonResult {
let mut send = match Send::find_by_uuid(&send_id, &conn) {
Some(s) => s,
None => err_code!(SEND_INACCESSIBLE_MSG, 404),
};
if let Some(max_access_count) = send.max_access_count {
if send.access_count >= max_access_count {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if let Some(expiration) = send.expiration_date {
if Utc::now().naive_utc() >= expiration {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().data.Password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
}
send.access_count += 1;
send.save(&conn)?;
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",
"Id": file_id,
"Url": format!("{}/api/sends/{}/{}?t={}", &host.host, send_id, file_id, token)
})))
}
#[get("/sends/<send_id>/<file_id>?<t>")]
fn download_send(send_id: String, file_id: String, t: String) -> 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)).ok();
}
}
None
}
#[put("/sends/<id>", data = "<data>")]
fn put_send(id: String, data: JsonUpcase<SendData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
enforce_disable_send_policy(&headers, &conn)?;
let data: SendData = data.into_inner().data;
enforce_disable_hide_email_policy(&data, &headers, &conn)?;
let mut send = match Send::find_by_uuid(&id, &conn) {
Some(s) => s,
None => err!("Send not found"),
};
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
err!("Send is not owned by user")
}
if send.atype != data.Type {
err!("Sends can't change type")
}
// When updating a file Send, we receive nulls in the File field, as it's immutable,
// so we only need to update the data field in the Text case
if data.Type == SendType::Text as i32 {
let data_str = if let Some(mut d) = data.Text {
d.as_object_mut().and_then(|d| d.remove("Response"));
serde_json::to_string(&d)?
} else {
err!("Send data not provided");
};
send.data = data_str;
}
if data.DeletionDate > Utc::now() + Duration::days(31) {
err!(
"You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again."
);
}
send.name = data.Name;
send.akey = data.Key;
send.deletion_date = data.DeletionDate.naive_utc();
send.notes = data.Notes;
send.max_access_count = data.MaxAccessCount;
send.expiration_date = data.ExpirationDate.map(|d| d.naive_utc());
send.hide_email = data.HideEmail;
send.disabled = data.Disabled;
// Only change the value if it's present
if let Some(password) = data.Password {
send.set_password(Some(&password));
}
send.save(&conn)?;
nt.send_user_update(UpdateType::SyncSendUpdate, &headers.user);
Ok(Json(send.to_json()))
}
#[delete("/sends/<id>")]
fn delete_send(id: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let send = match Send::find_by_uuid(&id, &conn) {
Some(s) => s,
None => err!("Send not found"),
};
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
err!("Send is not owned by user")
}
send.delete(&conn)?;
nt.send_user_update(UpdateType::SyncSendDelete, &headers.user);
Ok(())
}
#[put("/sends/<id>/remove-password")]
fn put_remove_password(id: String, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
enforce_disable_send_policy(&headers, &conn)?;
let mut send = match Send::find_by_uuid(&id, &conn) {
Some(s) => s,
None => err!("Send not found"),
};
if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
err!("Send is not owned by user")
}
send.set_password(None);
send.save(&conn)?;
nt.send_user_update(UpdateType::SyncSendUpdate, &headers.user);
Ok(Json(send.to_json()))
}

View File

@@ -17,11 +17,7 @@ use crate::{
pub use crate::config::CONFIG; pub use crate::config::CONFIG;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![generate_authenticator, activate_authenticator, activate_authenticator_put,]
generate_authenticator,
activate_authenticator,
activate_authenticator_put,
]
} }
#[post("/two-factor/get-authenticator", data = "<data>")] #[post("/two-factor/get-authenticator", data = "<data>")]
@@ -118,7 +114,7 @@ pub fn validate_totp_code_str(
_ => err!("TOTP code is not a number"), _ => err!("TOTP code is not a number"),
}; };
validate_totp_code(user_uuid, totp_code, secret, ip, &conn) validate_totp_code(user_uuid, totp_code, secret, ip, conn)
} }
pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &ClientIp, conn: &DbConn) -> EmptyResult { pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &ClientIp, conn: &DbConn) -> EmptyResult {
@@ -129,7 +125,7 @@ pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &Cl
Err(_) => err!("Invalid TOTP secret"), Err(_) => err!("Invalid TOTP secret"),
}; };
let mut twofactor = match TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Authenticator as i32, &conn) { let mut twofactor = match TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Authenticator as i32, conn) {
Some(tf) => tf, Some(tf) => tf,
_ => TwoFactor::new(user_uuid.to_string(), TwoFactorType::Authenticator, secret.to_string()), _ => TwoFactor::new(user_uuid.to_string(), TwoFactorType::Authenticator, secret.to_string()),
}; };
@@ -141,7 +137,7 @@ pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &Cl
// The amount of steps back and forward in time // The amount of steps back and forward in time
// Also check if we need to disable time drifted TOTP codes. // Also check if we need to disable time drifted TOTP codes.
// If that is the case, we set the steps to 0 so only the current TOTP is valid. // If that is the case, we set the steps to 0 so only the current TOTP is valid.
let steps: i64 = if CONFIG.authenticator_disable_time_drift() { 0 } else { 1 }; let steps = !CONFIG.authenticator_disable_time_drift() as i64;
for step in -steps..=steps { for step in -steps..=steps {
let time_step = current_timestamp / 30i64 + step; let time_step = current_timestamp / 30i64 + step;
@@ -160,25 +156,14 @@ pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &Cl
// Save the last used time step so only totp time steps higher then this one are allowed. // Save the last used time step so only totp time steps higher then this one are allowed.
// This will also save a newly created twofactor if the code is correct. // This will also save a newly created twofactor if the code is correct.
twofactor.last_used = time_step as i32; twofactor.last_used = time_step as i32;
twofactor.save(&conn)?; twofactor.save(conn)?;
return Ok(()); return Ok(());
} else if generated == totp_code && time_step <= twofactor.last_used as i64 { } else if generated == totp_code && time_step <= twofactor.last_used as i64 {
warn!( warn!("This or a TOTP code within {} steps back and forward has already been used!", steps);
"This or a TOTP code within {} steps back and forward has already been used!", err!(format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip));
steps
);
err!(format!(
"Invalid TOTP code! Server time: {} IP: {}",
current_time.format("%F %T UTC"),
ip.ip
));
} }
} }
// Else no valide code received, deny access // Else no valide code received, deny access
err!(format!( err!(format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip));
"Invalid TOTP code! Server time: {} IP: {}",
current_time.format("%F %T UTC"),
ip.ip
));
} }

View File

@@ -12,6 +12,7 @@ use crate::{
DbConn, DbConn,
}, },
error::MapResult, error::MapResult,
util::get_reqwest_client,
CONFIG, CONFIG,
}; };
@@ -59,7 +60,11 @@ impl DuoData {
ik.replace_range(digits.., replaced); ik.replace_range(digits.., replaced);
sk.replace_range(digits.., replaced); sk.replace_range(digits.., replaced);
Self { host, ik, sk } Self {
host,
ik,
sk,
}
} }
} }
@@ -185,9 +190,7 @@ fn activate_duo_put(data: JsonUpcase<EnableDuoData>, headers: Headers, conn: DbC
} }
fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
const AGENT: &str = "bitwarden_rs:Duo/1.0 (Rust)"; use reqwest::{header, Method};
use reqwest::{blocking::Client, header::*, Method};
use std::str::FromStr; use std::str::FromStr;
// https://duo.com/docs/authapi#api-details // https://duo.com/docs/authapi#api-details
@@ -199,11 +202,13 @@ fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> Em
let m = Method::from_str(method).unwrap_or_default(); let m = Method::from_str(method).unwrap_or_default();
Client::new() let client = get_reqwest_client();
client
.request(m, &url) .request(m, &url)
.basic_auth(username, Some(password)) .basic_auth(username, Some(password))
.header(USER_AGENT, AGENT) .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)")
.header(DATE, date) .header(header::DATE, date)
.send()? .send()?
.error_for_status()?; .error_for_status()?;
@@ -221,7 +226,7 @@ fn get_user_duo_data(uuid: &str, conn: &DbConn) -> DuoStatus {
let type_ = TwoFactorType::Duo as i32; let type_ = TwoFactorType::Duo as i32;
// If the user doesn't have an entry, disabled // If the user doesn't have an entry, disabled
let twofactor = match TwoFactor::find_by_user_and_type(uuid, type_, &conn) { let twofactor = match TwoFactor::find_by_user_and_type(uuid, type_, conn) {
Some(t) => t, Some(t) => t,
None => return DuoStatus::Disabled(DuoData::global().is_some()), None => return DuoStatus::Disabled(DuoData::global().is_some()),
}; };
@@ -242,8 +247,8 @@ fn get_user_duo_data(uuid: &str, conn: &DbConn) -> DuoStatus {
// let (ik, sk, ak, host) = get_duo_keys(); // let (ik, sk, ak, host) = get_duo_keys();
fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
let data = User::find_by_mail(email, &conn) let data = User::find_by_mail(email, conn)
.and_then(|u| get_user_duo_data(&u.uuid, &conn).data()) .and_then(|u| get_user_duo_data(&u.uuid, conn).data())
.or_else(DuoData::global) .or_else(DuoData::global)
.map_res("Can't fetch Duo keys")?; .map_res("Can't fetch Duo keys")?;
@@ -338,7 +343,7 @@ fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -
err!("Invalid ikey") err!("Invalid ikey")
} }
let expire = match expire.parse() { let expire: i64 = match expire.parse() {
Ok(e) => e, Ok(e) => e,
Err(_) => err!("Invalid expire time"), Err(_) => err!("Invalid expire time"),
}; };

View File

@@ -56,14 +56,14 @@ fn send_email_login(data: JsonUpcase<SendEmailLoginData>, conn: DbConn) -> Empty
/// Generate the token, save the data for later verification and send email to user /// Generate the token, save the data for later verification and send email to user
pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::Email as i32; let type_ = TwoFactorType::Email as i32;
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, &conn).map_res("Two factor not found")?; let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, conn).map_res("Two factor not found")?;
let generated_token = crypto::generate_token(CONFIG.email_token_size())?; let generated_token = crypto::generate_token(CONFIG.email_token_size())?;
let mut twofactor_data = EmailTokenData::from_json(&twofactor.data)?; let mut twofactor_data = EmailTokenData::from_json(&twofactor.data)?;
twofactor_data.set_token(generated_token); twofactor_data.set_token(generated_token);
twofactor.data = twofactor_data.to_json(); twofactor.data = twofactor_data.to_json();
twofactor.save(&conn)?; twofactor.save(conn)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
@@ -125,11 +125,7 @@ fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, conn: DbConn) -
let twofactor_data = EmailTokenData::new(data.Email, generated_token); let twofactor_data = EmailTokenData::new(data.Email, generated_token);
// Uses EmailVerificationChallenge as type to show that it's not verified yet. // Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor = TwoFactor::new( let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
user.uuid,
TwoFactorType::EmailVerificationChallenge,
twofactor_data.to_json(),
);
twofactor.save(&conn)?; twofactor.save(&conn)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
@@ -185,8 +181,9 @@ fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
/// Validate the email code when used as TwoFactor token mechanism /// Validate the email code when used as TwoFactor token mechanism
pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &DbConn) -> EmptyResult { pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &DbConn) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(&data)?; let mut email_data = EmailTokenData::from_json(data)?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn).map_res("Two factor not found")?; let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::Email as i32, conn)
.map_res("Two factor not found")?;
let issued_token = match &email_data.last_token { let issued_token = match &email_data.last_token {
Some(t) => t, Some(t) => t,
_ => err!("No token available"), _ => err!("No token available"),
@@ -198,14 +195,14 @@ pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &
email_data.reset_token(); email_data.reset_token();
} }
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
twofactor.save(&conn)?; twofactor.save(conn)?;
err!("Token is invalid") err!("Token is invalid")
} }
email_data.reset_token(); email_data.reset_token();
twofactor.data = email_data.to_json(); twofactor.data = email_data.to_json();
twofactor.save(&conn)?; twofactor.save(conn)?;
let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0); let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0);
let max_time = CONFIG.email_expiration_time() as i64; let max_time = CONFIG.email_expiration_time() as i64;
@@ -258,7 +255,7 @@ impl EmailTokenData {
} }
pub fn from_json(string: &str) -> Result<EmailTokenData, Error> { 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, crate::serde_json::Error> = serde_json::from_str(string);
match res { match res {
Ok(x) => Ok(x), Ok(x) => Ok(x),
Err(_) => err!("Could not decode EmailTokenData from string"), Err(_) => err!("Could not decode EmailTokenData from string"),
@@ -295,7 +292,7 @@ mod tests {
fn test_obscure_email_long() { fn test_obscure_email_long() {
let email = "bytes@example.ext"; let email = "bytes@example.ext";
let result = obscure_email(&email); let result = obscure_email(email);
// Only first two characters should be visible. // Only first two characters should be visible.
assert_eq!(result, "by***@example.ext"); assert_eq!(result, "by***@example.ext");
@@ -305,7 +302,7 @@ mod tests {
fn test_obscure_email_short() { fn test_obscure_email_short() {
let email = "byt@example.ext"; let email = "byt@example.ext";
let result = obscure_email(&email); let result = obscure_email(email);
// If it's smaller than 3 characters it should only show asterisks. // If it's smaller than 3 characters it should only show asterisks.
assert_eq!(result, "***@example.ext"); assert_eq!(result, "***@example.ext");

View File

@@ -17,36 +17,32 @@ pub mod authenticator;
pub mod duo; pub mod duo;
pub mod email; pub mod email;
pub mod u2f; pub mod u2f;
pub mod webauthn;
pub mod yubikey; pub mod yubikey;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut routes = routes![ let mut routes = routes![get_twofactor, get_recover, recover, disable_twofactor, disable_twofactor_put,];
get_twofactor,
get_recover,
recover,
disable_twofactor,
disable_twofactor_put,
];
routes.append(&mut authenticator::routes()); routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes()); routes.append(&mut duo::routes());
routes.append(&mut email::routes()); routes.append(&mut email::routes());
routes.append(&mut u2f::routes()); routes.append(&mut u2f::routes());
routes.append(&mut webauthn::routes());
routes.append(&mut yubikey::routes()); routes.append(&mut yubikey::routes());
routes routes
} }
#[get("/two-factor")] #[get("/two-factor")]
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult { fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn); let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect(); let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
Ok(Json(json!({ Json(json!({
"Data": twofactors_json, "Data": twofactors_json,
"Object": "list", "Object": "list",
"ContinuationToken": null, "ContinuationToken": null,
}))) }))
} }
#[post("/two-factor/get-recover", data = "<data>")] #[post("/two-factor/get-recover", data = "<data>")]

View File

@@ -28,13 +28,7 @@ static APP_ID: Lazy<String> = Lazy::new(|| format!("{}/app-id.json", &CONFIG.dom
static U2F: Lazy<U2f> = Lazy::new(|| U2f::new(APP_ID.clone())); static U2F: Lazy<U2f> = Lazy::new(|| U2f::new(APP_ID.clone()));
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![generate_u2f, generate_u2f_challenge, activate_u2f, activate_u2f_put, delete_u2f,]
generate_u2f,
generate_u2f_challenge,
activate_u2f,
activate_u2f_put,
delete_u2f,
]
} }
#[post("/two-factor/get-u2f", data = "<data>")] #[post("/two-factor/get-u2f", data = "<data>")]
@@ -100,13 +94,14 @@ struct RegistrationDef {
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct U2FRegistration { pub struct U2FRegistration {
id: i32, pub id: i32,
name: String, pub name: String,
#[serde(with = "RegistrationDef")] #[serde(with = "RegistrationDef")]
reg: Registration, pub reg: Registration,
counter: u32, pub counter: u32,
compromised: bool, compromised: bool,
pub migrated: Option<bool>,
} }
impl U2FRegistration { impl U2FRegistration {
@@ -131,12 +126,12 @@ struct RegisterResponseCopy {
pub error_code: Option<NumberOrString>, pub error_code: Option<NumberOrString>,
} }
impl Into<RegisterResponse> for RegisterResponseCopy { impl From<RegisterResponseCopy> for RegisterResponse {
fn into(self) -> RegisterResponse { fn from(r: RegisterResponseCopy) -> RegisterResponse {
RegisterResponse { RegisterResponse {
registration_data: self.registration_data, registration_data: r.registration_data,
version: self.version, version: r.version,
client_data: self.client_data, client_data: r.client_data,
} }
} }
} }
@@ -161,10 +156,7 @@ fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn)
let response: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?; let response: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?;
let error_code = response let error_code = response.error_code.clone().map_or("0".into(), NumberOrString::into_string);
.error_code
.clone()
.map_or("0".into(), NumberOrString::into_string);
if error_code != "0" { if error_code != "0" {
err!("Error registering U2F token") err!("Error registering U2F token")
@@ -177,6 +169,7 @@ fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn)
reg: registration, reg: registration,
compromised: false, compromised: false,
counter: 0, counter: 0,
migrated: None,
}; };
let mut regs = get_u2f_registrations(&user.uuid, &conn)?.1; let mut regs = get_u2f_registrations(&user.uuid, &conn)?.1;
@@ -255,7 +248,7 @@ fn _create_u2f_challenge(user_uuid: &str, type_: TwoFactorType, conn: &DbConn) -
} }
fn save_u2f_registrations(user_uuid: &str, regs: &[U2FRegistration], conn: &DbConn) -> EmptyResult { fn save_u2f_registrations(user_uuid: &str, regs: &[U2FRegistration], conn: &DbConn) -> EmptyResult {
TwoFactor::new(user_uuid.into(), TwoFactorType::U2f, serde_json::to_string(regs)?).save(&conn) TwoFactor::new(user_uuid.into(), TwoFactorType::U2f, serde_json::to_string(regs)?).save(conn)
} }
fn get_u2f_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<U2FRegistration>), Error> { fn get_u2f_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<U2FRegistration>), Error> {
@@ -282,10 +275,11 @@ fn get_u2f_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<U2
reg: old_regs.remove(0), reg: old_regs.remove(0),
compromised: false, compromised: false,
counter: 0, counter: 0,
migrated: None,
}]; }];
// Save new format // Save new format
save_u2f_registrations(user_uuid, &new_regs, &conn)?; save_u2f_registrations(user_uuid, &new_regs, conn)?;
new_regs new_regs
} }
@@ -300,20 +294,13 @@ fn _old_parse_registrations(registations: &str) -> Vec<Registration> {
let regs: Vec<Value> = serde_json::from_str(registations).expect("Can't parse Registration data"); let regs: Vec<Value> = serde_json::from_str(registations).expect("Can't parse Registration data");
regs.into_iter() regs.into_iter().map(|r| serde_json::from_value(r).unwrap()).map(|Helper(r)| r).collect()
.map(|r| serde_json::from_value(r).unwrap())
.map(|Helper(r)| r)
.collect()
} }
pub fn generate_u2f_login(user_uuid: &str, conn: &DbConn) -> ApiResult<U2fSignRequest> { pub fn generate_u2f_login(user_uuid: &str, conn: &DbConn) -> ApiResult<U2fSignRequest> {
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fLoginChallenge, conn); let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fLoginChallenge, conn);
let registrations: Vec<_> = get_u2f_registrations(user_uuid, conn)? let registrations: Vec<_> = get_u2f_registrations(user_uuid, conn)?.1.into_iter().map(|r| r.reg).collect();
.1
.into_iter()
.map(|r| r.reg)
.collect();
if registrations.is_empty() { if registrations.is_empty() {
err!("No U2F devices registered") err!("No U2F devices registered")
@@ -324,12 +311,12 @@ pub fn generate_u2f_login(user_uuid: &str, conn: &DbConn) -> ApiResult<U2fSignRe
pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult { pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult {
let challenge_type = TwoFactorType::U2fLoginChallenge as i32; let challenge_type = TwoFactorType::U2fLoginChallenge as i32;
let tf_challenge = TwoFactor::find_by_user_and_type(user_uuid, challenge_type, &conn); let tf_challenge = TwoFactor::find_by_user_and_type(user_uuid, challenge_type, conn);
let challenge = match tf_challenge { let challenge = match tf_challenge {
Some(tf_challenge) => { Some(tf_challenge) => {
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?; let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?;
tf_challenge.delete(&conn)?; tf_challenge.delete(conn)?;
challenge challenge
} }
None => err!("Can't recover login challenge"), None => err!("Can't recover login challenge"),
@@ -345,13 +332,13 @@ pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> Emp
match response { match response {
Ok(new_counter) => { Ok(new_counter) => {
reg.counter = new_counter; reg.counter = new_counter;
save_u2f_registrations(user_uuid, &registrations, &conn)?; save_u2f_registrations(user_uuid, &registrations, conn)?;
return Ok(()); return Ok(());
} }
Err(u2f::u2ferror::U2fError::CounterTooLow) => { Err(u2f::u2ferror::U2fError::CounterTooLow) => {
reg.compromised = true; reg.compromised = true;
save_u2f_registrations(user_uuid, &registrations, &conn)?; save_u2f_registrations(user_uuid, &registrations, conn)?;
err!("This device might be compromised!"); err!("This device might be compromised!");
} }

View File

@@ -0,0 +1,394 @@
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value;
use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState, RegistrationState, Webauthn};
use crate::{
api::{
core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData,
},
auth::Headers,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
error::Error,
CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
}
struct WebauthnConfig {
url: String,
rpid: String,
}
impl WebauthnConfig {
fn load() -> Webauthn<Self> {
let domain = CONFIG.domain();
Webauthn::new(Self {
rpid: reqwest::Url::parse(&domain)
.map(|u| u.domain().map(str::to_owned))
.ok()
.flatten()
.unwrap_or_default(),
url: domain,
})
}
}
impl webauthn_rs::WebauthnConfig for WebauthnConfig {
fn get_relying_party_name(&self) -> &str {
&self.url
}
fn get_origin(&self) -> &str {
&self.url
}
fn get_relying_party_id(&self) -> &str {
&self.rpid
}
}
impl webauthn_rs::WebauthnConfig for &WebauthnConfig {
fn get_relying_party_name(&self) -> &str {
&self.url
}
fn get_origin(&self) -> &str {
&self.url
}
fn get_relying_party_id(&self) -> &str {
&self.rpid
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WebauthnRegistration {
pub id: i32,
pub name: String,
pub migrated: bool,
pub credential: Credential,
}
impl WebauthnRegistration {
fn to_json(&self) -> Value {
json!({
"Id": self.id,
"Name": self.name,
"migrated": self.migrated,
})
}
}
#[post("/two-factor/get-webauthn", data = "<data>")]
fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
}
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) {
err!("Invalid password");
}
let (enabled, registrations) = get_webauthn_registrations(&headers.user.uuid, &conn)?;
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"Enabled": enabled,
"Keys": registrations_json,
"Object": "twoFactorWebAuthn"
})))
}
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) {
err!("Invalid password");
}
let registrations = get_webauthn_registrations(&headers.user.uuid, &conn)?
.1
.into_iter()
.map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering
.collect();
let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options(
headers.user.uuid.as_bytes().to_vec(),
headers.user.email,
headers.user.name,
Some(registrations),
None,
None,
)?;
let type_ = TwoFactorType::WebauthnRegisterChallenge;
TwoFactor::new(headers.user.uuid, type_, serde_json::to_string(&state)?).save(&conn)?;
let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into();
challenge_value["errorMessage"] = "".into();
Ok(Json(challenge_value))
}
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
struct EnableWebauthnData {
Id: NumberOrString, // 1..5
Name: String,
MasterPasswordHash: String,
DeviceResponse: RegisterPublicKeyCredentialCopy,
}
// This is copied from RegisterPublicKeyCredential to change the Response objects casing
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
struct RegisterPublicKeyCredentialCopy {
pub Id: String,
pub RawId: Base64UrlSafeData,
pub Response: AuthenticatorAttestationResponseRawCopy,
pub Type: String,
}
// This is copied from AuthenticatorAttestationResponseRaw to change clientDataJSON to clientDataJson
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
pub struct AuthenticatorAttestationResponseRawCopy {
pub AttestationObject: Base64UrlSafeData,
pub ClientDataJson: Base64UrlSafeData,
}
impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential {
fn from(r: RegisterPublicKeyCredentialCopy) -> Self {
Self {
id: r.Id,
raw_id: r.RawId,
response: AuthenticatorAttestationResponseRaw {
attestation_object: r.Response.AttestationObject,
client_data_json: r.Response.ClientDataJson,
},
type_: r.Type,
}
}
}
// This is copied from PublicKeyCredential to change the Response objects casing
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
pub struct PublicKeyCredentialCopy {
pub Id: String,
pub RawId: Base64UrlSafeData,
pub Response: AuthenticatorAssertionResponseRawCopy,
pub Extensions: Option<AuthenticationExtensionsClientOutputsCopy>,
pub Type: String,
}
// This is copied from AuthenticatorAssertionResponseRaw to change clientDataJSON to clientDataJson
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
pub struct AuthenticatorAssertionResponseRawCopy {
pub AuthenticatorData: Base64UrlSafeData,
pub ClientDataJson: Base64UrlSafeData,
pub Signature: Base64UrlSafeData,
pub UserHandle: Option<Base64UrlSafeData>,
}
#[derive(Debug, Deserialize)]
#[allow(non_snake_case)]
pub struct AuthenticationExtensionsClientOutputsCopy {
#[serde(default)]
pub Appid: bool,
}
impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
fn from(r: PublicKeyCredentialCopy) -> Self {
Self {
id: r.Id,
raw_id: r.RawId,
response: AuthenticatorAssertionResponseRaw {
authenticator_data: r.Response.AuthenticatorData,
client_data_json: r.Response.ClientDataJson,
signature: r.Response.Signature,
user_handle: r.Response.UserHandle,
},
extensions: r.Extensions.map(|e| AuthenticationExtensionsClientOutputs {
appid: e.Appid,
}),
type_: r.Type,
}
}
}
#[post("/two-factor/webauthn", data = "<data>")]
fn activate_webauthn(data: JsonUpcase<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableWebauthnData = data.into_inner().data;
let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
// Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) {
Some(tf) => {
let state: RegistrationState = serde_json::from_str(&tf.data)?;
tf.delete(&conn)?;
state
}
None => err!("Can't recover challenge"),
};
// Verify the credentials with the saved state
let (credential, _data) =
WebauthnConfig::load().register_credential(&data.DeviceResponse.into(), &state, |_| Ok(false))?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn)?.1;
// TODO: Check for repeated ID's
registrations.push(WebauthnRegistration {
id: data.Id.into_i32()?,
name: data.Name,
migrated: false,
credential,
});
// Save the registrations and return them
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?).save(&conn)?;
_generate_recover_code(&mut user, &conn);
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"Enabled": true,
"Keys": keys_json,
"Object": "twoFactorU2f"
})))
}
#[put("/two-factor/webauthn", data = "<data>")]
fn activate_webauthn_put(data: JsonUpcase<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult {
activate_webauthn(data, headers, conn)
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct DeleteU2FData {
Id: NumberOrString,
MasterPasswordHash: String,
}
#[delete("/two-factor/webauthn", data = "<data>")]
fn delete_webauthn(data: JsonUpcase<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
let id = data.data.Id.into_i32()?;
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) {
err!("Invalid password");
}
let type_ = TwoFactorType::Webauthn as i32;
let mut tf = match TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn) {
Some(tf) => tf,
None => 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 removed_item = data.remove(item_pos);
tf.data = serde_json::to_string(&data)?;
tf.save(&conn)?;
drop(tf);
// If entry is migrated from u2f, delete the u2f entry as well
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn) {
use crate::api::core::two_factor::u2f::U2FRegistration;
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
Ok(d) => d,
Err(_) => err!("Error parsing U2F data"),
};
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id);
let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str;
u2f.save(&conn)?;
}
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({
"Enabled": true,
"Keys": keys_json,
"Object": "twoFactorU2f"
})))
}
pub fn get_webauthn_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<WebauthnRegistration>), Error> {
let type_ = TwoFactorType::Webauthn as i32;
match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) {
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)),
None => Ok((false, Vec::new())), // If no data, return empty list
}
}
pub fn generate_webauthn_login(user_uuid: &str, conn: &DbConn) -> JsonResult {
// Load saved credentials
let creds: Vec<Credential> =
get_webauthn_registrations(user_uuid, conn)?.1.into_iter().map(|r| r.credential).collect();
if creds.is_empty() {
err!("No Webauthn devices registered")
}
// Generate a challenge based on the credentials
let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build();
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)?)
.save(conn)?;
// Return challenge to the clients
Ok(Json(serde_json::to_value(response.public_key)?))
}
pub fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
let state = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) {
Some(tf) => {
let state: AuthenticationState = serde_json::from_str(&tf.data)?;
tf.delete(conn)?;
state
}
None => err!("Can't recover login challenge"),
};
let rsp: crate::util::UpCase<PublicKeyCredentialCopy> = serde_json::from_str(response)?;
let rsp: PublicKeyCredential = rsp.data.into();
let mut registrations = get_webauthn_registrations(user_uuid, conn)?.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);
let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?;
for reg in &mut registrations {
if &reg.credential.cred_id == cred_id {
reg.credential.counter = auth_data.counter;
TwoFactor::new(user_uuid.to_string(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
.save(conn)?;
return Ok(());
}
}
err!("Credential not present")
}

View File

@@ -1,49 +1,105 @@
use std::{ use std::{
collections::HashMap,
fs::{create_dir_all, remove_file, symlink_metadata, File}, fs::{create_dir_all, remove_file, symlink_metadata, File},
io::prelude::*, io::prelude::*,
net::{IpAddr, ToSocketAddrs}, net::{IpAddr, ToSocketAddrs},
sync::{Arc, RwLock},
time::{Duration, SystemTime}, time::{Duration, SystemTime},
}; };
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use reqwest::{blocking::Client, blocking::Response, header::HeaderMap, Url}; use reqwest::{blocking::Client, blocking::Response, header};
use rocket::{http::ContentType, http::Cookie, response::Content, Route}; use rocket::{http::ContentType, response::Content, Route};
use soup::prelude::*;
use crate::{error::Error, util::Cached, CONFIG}; use crate::{
error::Error,
util::{get_reqwest_client_builder, Cached},
CONFIG,
};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![icon] routes![icon]
} }
const FALLBACK_ICON: &[u8; 344] = include_bytes!("../static/fallback-icon.png");
const ALLOWED_CHARS: &str = "_-.";
static CLIENT: Lazy<Client> = Lazy::new(|| { static CLIENT: Lazy<Client> = Lazy::new(|| {
// Generate the default headers
let mut default_headers = header::HeaderMap::new();
default_headers
.insert(header::USER_AGENT, header::HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)"));
default_headers
.insert(header::ACCEPT, header::HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1"));
default_headers.insert(header::ACCEPT_LANGUAGE, header::HeaderValue::from_static("en,*;q=0.1"));
default_headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-cache"));
default_headers.insert(header::PRAGMA, header::HeaderValue::from_static("no-cache"));
// Reuse the client between requests // Reuse the client between requests
Client::builder() get_reqwest_client_builder()
.cookie_provider(Arc::new(Jar::default()))
.timeout(Duration::from_secs(CONFIG.icon_download_timeout())) .timeout(Duration::from_secs(CONFIG.icon_download_timeout()))
.default_headers(_header_map()) .default_headers(default_headers)
.build() .build()
.unwrap() .expect("Failed to build icon client")
}); });
static ICON_REL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"icon$|apple.*icon").unwrap()); // Build Regex only once since this takes a lot of time.
static ICON_HREF_REGEX: Lazy<Regex> = static ICON_REL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)icon$|apple.*icon").unwrap());
Lazy::new(|| Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$|^data:image.*base64").unwrap()); static ICON_REL_BLACKLIST: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)mask-icon").unwrap());
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap());
// Special HashMap which holds the user defined Regex to speedup matching the regex.
static ICON_BLACKLIST_REGEX: Lazy<RwLock<HashMap<String, Regex>>> = Lazy::new(|| RwLock::new(HashMap::new()));
#[get("/<domain>/icon.png")]
fn icon(domain: String) -> Cached<Content<Vec<u8>>> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
if !is_valid_domain(&domain) {
warn!("Invalid domain: {}", domain);
return Cached::ttl(
Content(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
);
}
match get_icon(&domain) {
Some((icon, icon_type)) => {
Cached::ttl(Content(ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl())
}
_ => Cached::ttl(Content(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl()),
}
}
/// Returns if the domain provided is valid or not.
///
/// This does some manual checks and makes use of Url to do some basic checking.
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
fn is_valid_domain(domain: &str) -> bool { fn is_valid_domain(domain: &str) -> bool {
// Don't allow empty or too big domains or path traversal const ALLOWED_CHARS: &str = "_-.";
if domain.is_empty() || domain.len() > 255 || domain.contains("..") {
// If parsing the domain fails using Url, it will not work with reqwest.
if let Err(parse_error) = url::Url::parse(format!("https://{}", domain).as_str()) {
debug!("Domain parse error: '{}' - {:?}", domain, parse_error);
return false;
} else if domain.is_empty()
|| domain.contains("..")
|| domain.starts_with('.')
|| domain.starts_with('-')
|| domain.ends_with('-')
{
debug!(
"Domain validation error: '{}' is either empty, contains '..', starts with an '.', starts or ends with a '-'",
domain
);
return false;
} else if domain.len() > 255 {
debug!("Domain validation error: '{}' exceeds 255 characters", domain);
return false; return false;
} }
// Only alphanumeric or specific characters
for c in domain.chars() { for c in domain.chars() {
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) { if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
debug!("Domain validation error: '{}' contains an invalid character '{}'", domain, c);
return false; return false;
} }
} }
@@ -51,21 +107,10 @@ fn is_valid_domain(domain: &str) -> bool {
true true
} }
#[get("/<domain>/icon.png")]
fn icon(domain: String) -> Cached<Content<Vec<u8>>> {
let icon_type = ContentType::new("image", "x-icon");
if !is_valid_domain(&domain) {
warn!("Invalid domain: {:#?}", domain);
return Cached::long(Content(icon_type, FALLBACK_ICON.to_vec()));
}
Cached::long(Content(icon_type, get_icon(&domain)))
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable: /// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global /// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged /// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
#[allow(clippy::nonminimal_bool)]
#[cfg(not(feature = "unstable"))] #[cfg(not(feature = "unstable"))]
fn is_global(ip: IpAddr) -> bool { fn is_global(ip: IpAddr) -> bool {
match ip { match ip {
@@ -161,7 +206,7 @@ mod tests {
} }
} }
fn check_icon_domain_is_blacklisted(domain: &str) -> bool { fn is_domain_blacklisted(domain: &str) -> bool {
let mut is_blacklisted = CONFIG.icon_blacklist_non_global_ips() let mut is_blacklisted = CONFIG.icon_blacklist_non_global_ips()
&& (domain, 0) && (domain, 0)
.to_socket_addrs() .to_socket_addrs()
@@ -179,8 +224,32 @@ fn check_icon_domain_is_blacklisted(domain: &str) -> bool {
// Skip the regex check if the previous one is true already // Skip the regex check if the previous one is true already
if !is_blacklisted { if !is_blacklisted {
if let Some(blacklist) = CONFIG.icon_blacklist_regex() { if let Some(blacklist) = CONFIG.icon_blacklist_regex() {
let regex = Regex::new(&blacklist).expect("Valid Regex"); let mut regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap();
if regex.is_match(&domain) {
// Use the pre-generate Regex stored in a Lazy HashMap if there's one, else generate it.
let regex = if let Some(regex) = regex_hashmap.get(&blacklist) {
regex
} else {
drop(regex_hashmap);
let mut regex_hashmap_write = ICON_BLACKLIST_REGEX.write().unwrap();
// Clear the current list if the previous key doesn't exists.
// To prevent growing of the HashMap after someone has changed it via the admin interface.
if regex_hashmap_write.len() >= 1 {
regex_hashmap_write.clear();
}
// Generate the regex to store in too the Lazy Static HashMap.
let blacklist_regex = Regex::new(&blacklist).unwrap();
regex_hashmap_write.insert(blacklist.to_string(), blacklist_regex);
drop(regex_hashmap_write);
regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap();
regex_hashmap.get(&blacklist).unwrap()
};
// Use the pre-generate Regex stored in a Lazy HashMap.
if regex.is_match(domain) {
warn!("Blacklisted domain: {:#?} matched {:#?}", domain, blacklist); warn!("Blacklisted domain: {:#?} matched {:#?}", domain, blacklist);
is_blacklisted = true; is_blacklisted = true;
} }
@@ -190,39 +259,42 @@ fn check_icon_domain_is_blacklisted(domain: &str) -> bool {
is_blacklisted is_blacklisted
} }
fn get_icon(domain: &str) -> Vec<u8> { fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain); let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain);
// Check for expiration of negatively cached copy
if icon_is_negcached(&path) {
return None;
}
if let Some(icon) = get_cached_icon(&path) { if let Some(icon) = get_cached_icon(&path) {
return icon; let icon_type = match get_icon_type(&icon) {
Some(x) => x,
_ => "x-icon",
};
return Some((icon, icon_type.to_string()));
} }
if CONFIG.disable_icon_download() { if CONFIG.disable_icon_download() {
return FALLBACK_ICON.to_vec(); return None;
} }
// Get the icon, or fallback in case of error // Get the icon, or None in case of error
match download_icon(&domain) { match download_icon(domain) {
Ok(icon) => { Ok((icon, icon_type)) => {
save_icon(&path, &icon); save_icon(&path, &icon);
icon Some((icon, icon_type.unwrap_or("x-icon").to_string()))
} }
Err(e) => { Err(e) => {
error!("Error downloading icon: {:?}", e); error!("Error downloading icon: {:?}", e);
let miss_indicator = path + ".miss"; let miss_indicator = path + ".miss";
let empty_icon = Vec::new(); save_icon(&miss_indicator, &[]);
save_icon(&miss_indicator, &empty_icon); None
FALLBACK_ICON.to_vec()
} }
} }
} }
fn get_cached_icon(path: &str) -> Option<Vec<u8>> { fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
// Check for expiration of negatively cached copy
if icon_is_negcached(path) {
return Some(FALLBACK_ICON.to_vec());
}
// Check for expiration of successfully cached copy // Check for expiration of successfully cached copy
if icon_is_expired(path) { if icon_is_expired(path) {
return None; return None;
@@ -272,7 +344,6 @@ fn icon_is_expired(path: &str) -> bool {
expired.unwrap_or(true) expired.unwrap_or(true)
} }
#[derive(Debug)]
struct Icon { struct Icon {
priority: u8, priority: u8,
href: String, href: String,
@@ -280,12 +351,108 @@ struct Icon {
impl Icon { impl Icon {
const fn new(priority: u8, href: String) -> Self { const fn new(priority: u8, href: String) -> Self {
Self { href, priority } Self {
priority,
href,
}
} }
} }
/// Returns a Result/Tuple which holds a Vector IconList and a string which holds the cookies from the last response. /// Iterates over the HTML document to find <base href="http://domain.tld">
/// There will always be a result with a string which will contain https://example.com/favicon.ico and an empty string for the cookies. /// When found it will stop the iteration and the found base href will be shared deref via `base_href`.
///
/// # Arguments
/// * `node` - A Parsed HTML document via html5ever::parse_document()
/// * `base_href` - a mutable url::Url which will be overwritten when a base href tag has been found.
///
fn get_base_href(node: &std::rc::Rc<markup5ever_rcdom::Node>, base_href: &mut url::Url) -> bool {
if let markup5ever_rcdom::NodeData::Element {
name,
attrs,
..
} = &node.data
{
if name.local.as_ref() == "base" {
let attrs = attrs.borrow();
for attr in attrs.iter() {
let attr_name = attr.name.local.as_ref();
let attr_value = attr.value.as_ref();
if attr_name == "href" {
debug!("Found base href: {}", attr_value);
*base_href = match base_href.join(attr_value) {
Ok(href) => href,
_ => base_href.clone(),
};
return true;
}
}
return true;
}
}
// TODO: Might want to limit the recursion depth?
for child in node.children.borrow().iter() {
// Check if we got a true back and stop the iter.
// This means we found a <base> tag and can stop processing the html.
if get_base_href(child, base_href) {
return true;
}
}
false
}
fn get_favicons_node(node: &std::rc::Rc<markup5ever_rcdom::Node>, icons: &mut Vec<Icon>, url: &url::Url) {
if let markup5ever_rcdom::NodeData::Element {
name,
attrs,
..
} = &node.data
{
if name.local.as_ref() == "link" {
let mut has_rel = false;
let mut href = None;
let mut sizes = None;
let attrs = attrs.borrow();
for attr in attrs.iter() {
let attr_name = attr.name.local.as_ref();
let attr_value = attr.value.as_ref();
if attr_name == "rel" && ICON_REL_REGEX.is_match(attr_value) && !ICON_REL_BLACKLIST.is_match(attr_value)
{
has_rel = true;
} else if attr_name == "href" {
href = Some(attr_value);
} else if attr_name == "sizes" {
sizes = Some(attr_value);
}
}
if has_rel {
if let Some(inner_href) = href {
if let Ok(full_href) = url.join(inner_href).map(String::from) {
let priority = get_icon_priority(&full_href, sizes);
icons.push(Icon::new(priority, full_href));
}
}
}
}
}
// TODO: Might want to limit the recursion depth?
for child in node.children.borrow().iter() {
get_favicons_node(child, icons, url);
}
}
struct IconUrlResult {
iconlist: Vec<Icon>,
referer: String,
}
/// Returns a IconUrlResult which holds a Vector IconList and a string which holds the referer.
/// There will always two items within the iconlist which holds http(s)://domain.tld/favicon.ico.
/// This does not mean that that location does exists, but it is the default location browser use. /// This does not mean that that location does exists, but it is the default location browser use.
/// ///
/// # Argument /// # Argument
@@ -293,65 +460,79 @@ impl Icon {
/// ///
/// # Example /// # Example
/// ``` /// ```
/// let (mut iconlist, cookie_str) = get_icon_url("github.com")?; /// let icon_result = get_icon_url("github.com")?;
/// let (mut iconlist, cookie_str) = get_icon_url("gitlab.com")?; /// let icon_result = get_icon_url("vaultwarden.discourse.group")?;
/// ``` /// ```
fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> { fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
// Default URL with secure and insecure schemes // Default URL with secure and insecure schemes
let ssldomain = format!("https://{}", domain); let ssldomain = format!("https://{}", domain);
let httpdomain = format!("http://{}", domain); let httpdomain = format!("http://{}", domain);
// First check the domain as given during the request for both HTTPS and HTTP.
let resp = match get_page(&ssldomain).or_else(|_| get_page(&httpdomain)) {
Ok(c) => Ok(c),
Err(e) => {
let mut sub_resp = Err(e);
// When the domain is not an IP, and has more then one dot, remove all subdomains.
let is_ip = domain.parse::<IpAddr>();
if is_ip.is_err() && domain.matches('.').count() > 1 {
let mut domain_parts = domain.split('.');
let base_domain = format!(
"{base}.{tld}",
tld = domain_parts.next_back().unwrap(),
base = domain_parts.next_back().unwrap()
);
if is_valid_domain(&base_domain) {
let sslbase = format!("https://{}", base_domain);
let httpbase = format!("http://{}", base_domain);
debug!("[get_icon_url]: Trying without subdomains '{}'", base_domain);
sub_resp = get_page(&sslbase).or_else(|_| get_page(&httpbase));
}
// When the domain is not an IP, and has less then 2 dots, try to add www. infront of it.
} else if is_ip.is_err() && domain.matches('.').count() < 2 {
let www_domain = format!("www.{}", domain);
if is_valid_domain(&www_domain) {
let sslwww = format!("https://{}", www_domain);
let httpwww = format!("http://{}", www_domain);
debug!("[get_icon_url]: Trying with www. prefix '{}'", www_domain);
sub_resp = get_page(&sslwww).or_else(|_| get_page(&httpwww));
}
}
sub_resp
}
};
// Create the iconlist // Create the iconlist
let mut iconlist: Vec<Icon> = Vec::new(); let mut iconlist: Vec<Icon> = Vec::new();
let mut referer = String::from("");
// Create the cookie_str to fill it all the cookies from the response
// These cookies can be used to request/download the favicon image.
// Some sites have extra security in place with for example XSRF Tokens.
let mut cookie_str = String::new();
let resp = get_page(&ssldomain).or_else(|_| get_page(&httpdomain));
if let Ok(content) = resp { if let Ok(content) = resp {
// Extract the URL from the respose in case redirects occured (like @ gitlab.com) // Extract the URL from the respose in case redirects occured (like @ gitlab.com)
let url = content.url().clone(); let url = content.url().clone();
let raw_cookies = content.headers().get_all("set-cookie"); // Set the referer to be used on the final request, some sites check this.
cookie_str = raw_cookies // Mostly used to prevent direct linking and other security resons.
.iter() referer = url.as_str().to_string();
.filter_map(|raw_cookie| raw_cookie.to_str().ok())
.map(|cookie_str| {
if let Ok(cookie) = Cookie::parse(cookie_str) {
format!("{}={}; ", cookie.name(), cookie.value())
} else {
String::new()
}
})
.collect::<String>();
// Add the default favicon.ico to the list with the domain the content responded from. // Add the default favicon.ico to the list with the domain the content responded from.
iconlist.push(Icon::new(35, url.join("/favicon.ico").unwrap().into_string())); iconlist.push(Icon::new(35, String::from(url.join("/favicon.ico").unwrap())));
// 512KB should be more than enough for the HTML, though as we only really need // 384KB should be more than enough for the HTML, though as we only really need the HTML header.
// the HTML header, it could potentially be reduced even further let mut limited_reader = content.take(384 * 1024);
let limited_reader = content.take(512 * 1024);
let soup = Soup::from_reader(limited_reader)?; use html5ever::tendril::TendrilSink;
// Search for and filter let dom = html5ever::parse_document(markup5ever_rcdom::RcDom::default(), Default::default())
let favicons = soup .from_utf8()
.tag("link") .read_from(&mut limited_reader)?;
.attr("rel", ICON_REL_REGEX.clone()) // Only use icon rels
.attr("href", ICON_HREF_REGEX.clone()) // Only allow specific extensions
.find_all();
// Loop through all the found icons and determine it's priority let mut base_url: url::Url = url;
for favicon in favicons { get_base_href(&dom.document, &mut base_url);
let sizes = favicon.get("sizes"); get_favicons_node(&dom.document, &mut iconlist, &base_url);
let href = favicon.get("href").expect("Missing href");
let full_href = url.join(&href).unwrap().into_string();
let priority = get_icon_priority(&full_href, sizes);
iconlist.push(Icon::new(priority, full_href))
}
} else { } else {
// Add the default favicon.ico to the list with just the given domain // Add the default favicon.ico to the list with just the given domain
iconlist.push(Icon::new(35, format!("{}/favicon.ico", ssldomain))); iconlist.push(Icon::new(35, format!("{}/favicon.ico", ssldomain)));
@@ -362,28 +543,27 @@ fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
iconlist.sort_by_key(|x| x.priority); iconlist.sort_by_key(|x| x.priority);
// There always is an icon in the list, so no need to check if it exists, and just return the first one // There always is an icon in the list, so no need to check if it exists, and just return the first one
Ok((iconlist, cookie_str)) Ok(IconUrlResult {
iconlist,
referer,
})
} }
fn get_page(url: &str) -> Result<Response, Error> { fn get_page(url: &str) -> Result<Response, Error> {
get_page_with_cookies(url, "") get_page_with_referer(url, "")
} }
fn get_page_with_cookies(url: &str, cookie_str: &str) -> Result<Response, Error> { fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
if check_icon_domain_is_blacklisted(Url::parse(url).unwrap().host_str().unwrap_or_default()) { if is_domain_blacklisted(url::Url::parse(url).unwrap().host_str().unwrap_or_default()) {
err!("Favicon rel linked to a non blacklisted domain!"); err!("Favicon rel linked to a blacklisted domain!");
} }
if cookie_str.is_empty() { let mut client = CLIENT.get(url);
CLIENT.get(url).send()?.error_for_status().map_err(Into::into) if !referer.is_empty() {
} else { client = client.header("Referer", referer)
CLIENT
.get(url)
.header("cookie", cookie_str)
.send()?
.error_for_status()
.map_err(Into::into)
} }
client.send()?.error_for_status().map_err(Into::into)
} }
/// Returns a Integer with the priority of the type of the icon which to prefer. /// Returns a Integer with the priority of the type of the icon which to prefer.
@@ -398,7 +578,7 @@ fn get_page_with_cookies(url: &str, cookie_str: &str) -> Result<Response, Error>
/// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32"); /// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32");
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", ""); /// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
/// ``` /// ```
fn get_icon_priority(href: &str, sizes: Option<String>) -> u8 { fn get_icon_priority(href: &str, sizes: Option<&str>) -> u8 {
// Check if there is a dimension set // Check if there is a dimension set
let (width, height) = parse_sizes(sizes); let (width, height) = parse_sizes(sizes);
@@ -411,7 +591,7 @@ fn get_icon_priority(href: &str, sizes: Option<String>) -> u8 {
1 1
} else if width == 64 { } else if width == 64 {
2 2
} else if width >= 24 && width <= 128 { } else if (24..=192).contains(&width) {
3 3
} else if width == 16 { } else if width == 16 {
4 4
@@ -446,7 +626,7 @@ fn get_icon_priority(href: &str, sizes: Option<String>) -> u8 {
/// let (width, height) = parse_sizes("x128x128"); // (128, 128) /// let (width, height) = parse_sizes("x128x128"); // (128, 128)
/// let (width, height) = parse_sizes("32"); // (0, 0) /// let (width, height) = parse_sizes("32"); // (0, 0)
/// ``` /// ```
fn parse_sizes(sizes: Option<String>) -> (u16, u16) { fn parse_sizes(sizes: Option<&str>) -> (u16, u16) {
let mut width: u16 = 0; let mut width: u16 = 0;
let mut height: u16 = 0; let mut height: u16 = 0;
@@ -465,18 +645,19 @@ fn parse_sizes(sizes: Option<String>) -> (u16, u16) {
(width, height) (width, height)
} }
fn download_icon(domain: &str) -> Result<Vec<u8>, Error> { fn download_icon(domain: &str) -> Result<(Vec<u8>, Option<&str>), Error> {
if check_icon_domain_is_blacklisted(domain) { if is_domain_blacklisted(domain) {
err!("Domain is blacklisted", domain) err!("Domain is blacklisted", domain)
} }
let (iconlist, cookie_str) = get_icon_url(&domain)?; let icon_result = get_icon_url(domain)?;
let mut buffer = Vec::new(); let mut buffer = Vec::new();
let mut icon_type: Option<&str> = None;
use data_url::DataUrl; use data_url::DataUrl;
for icon in iconlist.iter().take(5) { for icon in icon_result.iconlist.iter().take(5) {
if icon.href.starts_with("data:image") { if icon.href.starts_with("data:image") {
let datauri = DataUrl::process(&icon.href).unwrap(); let datauri = DataUrl::process(&icon.href).unwrap();
// Check if we are able to decode the data uri // Check if we are able to decode the data uri
@@ -484,29 +665,43 @@ fn download_icon(domain: &str) -> Result<Vec<u8>, Error> {
Ok((body, _fragment)) => { Ok((body, _fragment)) => {
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
if body.len() >= 67 { if body.len() >= 67 {
// Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&body);
if icon_type.is_none() {
debug!("Icon from {} data:image uri, is not a valid image type", domain);
continue;
}
info!("Extracted icon from data:image uri for {}", domain);
buffer = body; buffer = body;
break; break;
} }
} }
_ => warn!("data uri is invalid"), _ => warn!("Extracted icon from data:image uri is invalid"),
}; };
} else { } else {
match get_page_with_cookies(&icon.href, &cookie_str) { match get_page_with_referer(&icon.href, &icon_result.referer) {
Ok(mut res) => { Ok(mut res) => {
info!("Downloaded icon from {}", icon.href);
res.copy_to(&mut buffer)?; res.copy_to(&mut buffer)?;
// Check if the icon type is allowed, else try an icon from the list.
icon_type = get_icon_type(&buffer);
if icon_type.is_none() {
buffer.clear();
debug!("Icon from {}, is not a valid image type", icon.href);
continue;
}
info!("Downloaded icon from {}", icon.href);
break; break;
} }
Err(_) => info!("Download failed for {}", icon.href), _ => warn!("Download failed for {}", icon.href),
}; };
} }
} }
if buffer.is_empty() { if buffer.is_empty() {
err!("Empty response") err!("Empty response downloading icon")
} }
Ok(buffer) Ok((buffer, icon_type))
} }
fn save_icon(path: &str, icon: &[u8]) { fn save_icon(path: &str, icon: &[u8]) {
@@ -518,29 +713,65 @@ fn save_icon(path: &str, icon: &[u8]) {
create_dir_all(&CONFIG.icon_cache_folder()).expect("Error creating icon cache"); create_dir_all(&CONFIG.icon_cache_folder()).expect("Error creating icon cache");
} }
Err(e) => { Err(e) => {
info!("Icon save error: {:?}", e); warn!("Icon save error: {:?}", e);
} }
} }
} }
fn _header_map() -> HeaderMap { fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
// Set some default headers for the request. match bytes {
// Use a browser like user-agent to make sure most websites will return there correct website. [137, 80, 78, 71, ..] => Some("png"),
use reqwest::header::*; [0, 0, 1, 0, ..] => Some("x-icon"),
[82, 73, 70, 70, ..] => Some("webp"),
macro_rules! headers { [255, 216, 255, ..] => Some("jpeg"),
($( $name:ident : $value:literal),+ $(,)? ) => { [71, 73, 70, 56, ..] => Some("gif"),
let mut headers = HeaderMap::new(); [66, 77, ..] => Some("bmp"),
$( headers.insert($name, HeaderValue::from_static($value)); )+ _ => None,
headers }
}; }
}
/// This is an implementation of the default Cookie Jar from Reqwest and reqwest_cookie_store build by pfernie.
headers! { /// The default cookie jar used by Reqwest keeps all the cookies based upon the Max-Age or Expires which could be a long time.
USER_AGENT: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299", /// That could be used for tracking, to prevent this we force the lifespan of the cookies to always be max two minutes.
ACCEPT_LANGUAGE: "en-US,en;q=0.8", /// A Cookie Jar is needed because some sites force a redirect with cookies to verify if a request uses cookies or not.
CACHE_CONTROL: "no-cache", use cookie_store::CookieStore;
PRAGMA: "no-cache", #[derive(Default)]
ACCEPT: "text/html,application/xhtml+xml,application/xml; q=0.9,image/webp,image/apng,*/*;q=0.8", pub struct Jar(RwLock<CookieStore>);
impl reqwest::cookie::CookieStore for Jar {
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &header::HeaderValue>, url: &url::Url) {
use cookie::{Cookie as RawCookie, ParseError as RawCookieParseError};
use time::Duration;
let mut cookie_store = self.0.write().unwrap();
let cookies = cookie_headers.filter_map(|val| {
std::str::from_utf8(val.as_bytes())
.map_err(RawCookieParseError::from)
.and_then(RawCookie::parse)
.map(|mut c| {
c.set_expires(None);
c.set_max_age(Some(Duration::minutes(2)));
c.into_owned()
})
.ok()
});
cookie_store.store_response_cookies(cookies, url);
}
fn cookies(&self, url: &url::Url) -> Option<header::HeaderValue> {
use bytes::Bytes;
let cookie_store = self.0.read().unwrap();
let s = cookie_store
.get_request_values(url)
.map(|(name, value)| format!("{}={}", name, value))
.collect::<Vec<_>>()
.join("; ");
if s.is_empty() {
return None;
}
header::HeaderValue::from_maybe_shared(Bytes::from(s)).ok()
} }
} }

View File

@@ -72,7 +72,8 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
"Kdf": user.client_kdf_type, "Kdf": user.client_kdf_type,
"KdfIterations": user.client_kdf_iter, "KdfIterations": user.client_kdf_iter,
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
"scope": "api offline_access" "scope": "api offline_access",
"unofficialServer": true,
}))) })))
} }
@@ -87,26 +88,28 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
let username = data.username.as_ref().unwrap(); let username = data.username.as_ref().unwrap();
let user = match User::find_by_mail(username, &conn) { let user = match User::find_by_mail(username, &conn) {
Some(user) => user, Some(user) => user,
None => err!( None => err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username)),
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", ip.ip, username)
),
}; };
// Check password // Check password
let password = data.password.as_ref().unwrap(); let password = data.password.as_ref().unwrap();
if !user.check_valid_password(password) { if !user.check_valid_password(password) {
err!( err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username))
"Username or password is incorrect. Try again", }
format!("IP: {}. Username: {}.", ip.ip, username)
) // Check if the user is disabled
if !user.enabled {
err!("This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, username))
} }
let now = Local::now(); let now = Local::now();
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
let now = now.naive_utc(); let now = now.naive_utc();
if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() > CONFIG.signups_verify_resend_time() as i64 { if user.last_verifying_at.is_none()
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
> CONFIG.signups_verify_resend_time() as i64
{
let resend_limit = CONFIG.signups_verify_resend_limit() as i32; let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
if resend_limit == 0 || user.login_verify_count < resend_limit { if resend_limit == 0 || user.login_verify_count < resend_limit {
// We want to send another email verification if we require signups to verify // We want to send another email verification if we require signups to verify
@@ -126,15 +129,12 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
} }
// We still want the login to fail until they actually verified the email address // We still want the login to fail until they actually verified the email address
err!( err!("Please verify your email before trying again.", format!("IP: {}. Username: {}.", ip.ip, username))
"Please verify your email before trying again.",
format!("IP: {}. Username: {}.", ip.ip, username)
)
} }
let (mut device, new_device) = get_device(&data, &conn, &user); let (mut device, new_device) = get_device(&data, &conn, &user);
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &ip, &conn)?; let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, ip, &conn)?;
if CONFIG.mail_enabled() && new_device { 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) { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name) {
@@ -160,11 +160,12 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult
"Key": user.akey, "Key": user.akey,
"PrivateKey": user.private_key, "PrivateKey": user.private_key,
//"TwoFactorToken": "11122233333444555666777888999" //"TwoFactorToken": "11122233333444555666777888999"
"Kdf": user.client_kdf_type, "Kdf": user.client_kdf_type,
"KdfIterations": user.client_kdf_iter, "KdfIterations": user.client_kdf_iter,
"ResetMasterPassword": false,// TODO: Same as above "ResetMasterPassword": false,// TODO: Same as above
"scope": "api offline_access" "scope": "api offline_access",
"unofficialServer": true,
}); });
if let Some(token) = twofactor_token { if let Some(token) = twofactor_token {
@@ -184,7 +185,7 @@ fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> (Device, bool)
let mut new_device = false; let mut new_device = false;
// Find device or create new // Find device or create new
let device = match Device::find_by_uuid(&device_id, &conn) { let device = match Device::find_by_uuid(&device_id, conn) {
Some(device) => { Some(device) => {
// Check if owned device, and recreate if not // Check if owned device, and recreate if not
if device.user_uuid != user.uuid { if device.user_uuid != user.uuid {
@@ -226,9 +227,7 @@ fn twofactor_auth(
None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?, "2FA token not provided"), None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?, "2FA token not provided"),
}; };
let selected_twofactor = twofactors let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
.into_iter()
.find(|tf| tf.atype == selected_id && tf.enabled);
use crate::api::core::two_factor as _tf; use crate::api::core::two_factor as _tf;
use crate::crypto::ct_eq; use crate::crypto::ct_eq;
@@ -237,18 +236,27 @@ fn twofactor_auth(
let mut remember = data.two_factor_remember.unwrap_or(0); let mut remember = data.two_factor_remember.unwrap_or(0);
match TwoFactorType::from_i32(selected_id) { match TwoFactorType::from_i32(selected_id) {
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, ip, conn)?, Some(TwoFactorType::Authenticator) => {
_tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, ip, conn)?
}
Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?, Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?,
Some(TwoFactorType::Webauthn) => _tf::webauthn::validate_webauthn_login(user_uuid, twofactor_code, conn)?,
Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?, Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?,
Some(TwoFactorType::Duo) => _tf::duo::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?, Some(TwoFactorType::Duo) => {
Some(TwoFactorType::Email) => _tf::email::validate_email_code_str(user_uuid, twofactor_code, &selected_data?, conn)?, _tf::duo::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?
}
Some(TwoFactorType::Email) => {
_tf::email::validate_email_code_str(user_uuid, twofactor_code, &selected_data?, conn)?
}
Some(TwoFactorType::Remember) => { Some(TwoFactorType::Remember) => {
match device.twofactor_remember { match device.twofactor_remember {
Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => { Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => {
remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time
} }
_ => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?, "2FA Remember token not provided"), _ => {
err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?, "2FA Remember token not provided")
}
} }
} }
_ => err!("Invalid two factor provider"), _ => err!("Invalid two factor provider"),
@@ -302,8 +310,13 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
}); });
} }
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
let request = two_factor::webauthn::generate_webauthn_login(user_uuid, conn)?;
result["TwoFactorProviders2"][provider.to_string()] = request.0;
}
Some(TwoFactorType::Duo) => { Some(TwoFactorType::Duo) => {
let email = match User::find_by_uuid(user_uuid, &conn) { let email = match User::find_by_uuid(user_uuid, conn) {
Some(u) => u.email, Some(u) => u.email,
None => err!("User does not exist"), None => err!("User does not exist"),
}; };
@@ -317,7 +330,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
} }
Some(tf_type @ TwoFactorType::YubiKey) => { Some(tf_type @ TwoFactorType::YubiKey) => {
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, &conn) { let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn) {
Some(tf) => tf, Some(tf) => tf,
None => err!("No YubiKey devices registered"), None => err!("No YubiKey devices registered"),
}; };
@@ -332,14 +345,14 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
Some(tf_type @ TwoFactorType::Email) => { Some(tf_type @ TwoFactorType::Email) => {
use crate::api::core::two_factor as _tf; use crate::api::core::two_factor as _tf;
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, &conn) { let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, conn) {
Some(tf) => tf, Some(tf) => tf,
None => err!("No twofactor email registered"), None => err!("No twofactor email registered"),
}; };
// Send email immediately if email is the only 2FA option // Send email immediately if email is the only 2FA option
if providers.len() == 1 { if providers.len() == 1 {
_tf::email::send_token(&user_uuid, &conn)? _tf::email::send_token(user_uuid, conn)?
} }
let email_data = EmailTokenData::from_json(&twofactor.data)?; let email_data = EmailTokenData::from_json(&twofactor.data)?;

View File

@@ -10,6 +10,8 @@ use serde_json::Value;
pub use crate::api::{ pub use crate::api::{
admin::routes as admin_routes, admin::routes as admin_routes,
core::purge_sends,
core::purge_trashed_ciphers,
core::routes as core_routes, core::routes as core_routes,
icons::routes as icons_routes, icons::routes as icons_routes,
identity::routes as identity_routes, identity::routes as identity_routes,
@@ -49,13 +51,14 @@ impl NumberOrString {
} }
} }
fn into_i32(self) -> ApiResult<i32> { #[allow(clippy::wrong_self_convention)]
fn into_i32(&self) -> ApiResult<i32> {
use std::num::ParseIntError as PIE; use std::num::ParseIntError as PIE;
match self { match self {
NumberOrString::Number(n) => Ok(n), NumberOrString::Number(n) => Ok(*n),
NumberOrString::String(s) => s NumberOrString::String(s) => {
.parse() s.parse().map_err(|e: PIE| crate::Error::new("Can't convert to number", e.to_string()))
.map_err(|e: PIE| crate::Error::new("Can't convert to number", e.to_string())), }
} }
} }
} }

View File

@@ -4,12 +4,7 @@ use rocket::Route;
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use crate::{ use crate::{api::EmptyResult, auth::Headers, db::DbConn, Error, CONFIG};
api::{EmptyResult, JsonResult},
auth::Headers,
db::DbConn,
Error, CONFIG,
};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![negotiate, websockets_err] routes![negotiate, websockets_err]
@@ -19,12 +14,15 @@ static SHOW_WEBSOCKETS_MSG: AtomicBool = AtomicBool::new(true);
#[get("/hub")] #[get("/hub")]
fn websockets_err() -> EmptyResult { fn websockets_err() -> EmptyResult {
if CONFIG.websocket_enabled() && SHOW_WEBSOCKETS_MSG.compare_and_swap(true, false, Ordering::Relaxed) { if CONFIG.websocket_enabled()
&& SHOW_WEBSOCKETS_MSG.compare_exchange(true, false, Ordering::Relaxed, Ordering::Relaxed).is_ok()
{
err!( err!(
"########################################################### "
###########################################################
'/notifications/hub' should be proxied to the websocket server or notifications won't work. '/notifications/hub' should be proxied to the websocket server or notifications won't work.
Go to the Wiki for more info, or disable WebSockets setting WEBSOCKET_ENABLED=false. Go to the Wiki for more info, or disable WebSockets setting WEBSOCKET_ENABLED=false.
###########################################################################################" ###########################################################################################\n"
) )
} else { } else {
Err(Error::empty()) Err(Error::empty())
@@ -32,7 +30,7 @@ fn websockets_err() -> EmptyResult {
} }
#[post("/hub/negotiate")] #[post("/hub/negotiate")]
fn negotiate(_headers: Headers, _conn: DbConn) -> JsonResult { fn negotiate(_headers: Headers, _conn: DbConn) -> Json<JsonValue> {
use crate::crypto; use crate::crypto;
use data_encoding::BASE64URL; use data_encoding::BASE64URL;
@@ -48,10 +46,10 @@ fn negotiate(_headers: Headers, _conn: DbConn) -> JsonResult {
// Rocket SSE support: https://github.com/SergioBenitez/Rocket/issues/33 // Rocket SSE support: https://github.com/SergioBenitez/Rocket/issues/33
// {"transport":"ServerSentEvents", "transferFormats":["Text"]}, // {"transport":"ServerSentEvents", "transferFormats":["Text"]},
// {"transport":"LongPolling", "transferFormats":["Text","Binary"]} // {"transport":"LongPolling", "transferFormats":["Text","Binary"]}
Ok(Json(json!({ Json(json!({
"connectionId": conn_id, "connectionId": conn_id,
"availableTransports": available_transports "availableTransports": available_transports
}))) }))
} }
// //
@@ -121,7 +119,7 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
} }
// Server WebSocket handler // Server WebSocket handler
pub struct WSHandler { pub struct WsHandler {
out: Sender, out: Sender,
user_uuid: Option<String>, user_uuid: Option<String>,
users: WebSocketUsers, users: WebSocketUsers,
@@ -141,7 +139,7 @@ const PING: Token = Token(1);
const ACCESS_TOKEN_KEY: &str = "access_token="; const ACCESS_TOKEN_KEY: &str = "access_token=";
impl WSHandler { impl WsHandler {
fn err(&self, msg: &'static str) -> ws::Result<()> { fn err(&self, msg: &'static str) -> ws::Result<()> {
self.out.close(ws::CloseCode::Invalid)?; self.out.close(ws::CloseCode::Invalid)?;
@@ -161,14 +159,14 @@ impl WSHandler {
} }
} }
}; };
// Otherwise verify the query parameter value // Otherwise verify the query parameter value
let path = hs.request.resource(); let path = hs.request.resource();
if let Some(params) = path.split('?').nth(1) { if let Some(params) = path.split('?').nth(1) {
let params_iter = params.split('&').take(1); let params_iter = params.split('&').take(1);
for val in params_iter { for val in params_iter {
if val.starts_with(ACCESS_TOKEN_KEY) { if let Some(stripped) = val.strip_prefix(ACCESS_TOKEN_KEY) {
return Some(val[ACCESS_TOKEN_KEY.len()..].into()); return Some(stripped.into());
} }
} }
}; };
@@ -177,7 +175,7 @@ impl WSHandler {
} }
} }
impl Handler for WSHandler { impl Handler for WsHandler {
fn on_open(&mut self, hs: Handshake) -> ws::Result<()> { fn on_open(&mut self, hs: Handshake) -> ws::Result<()> {
// Path == "/notifications/hub?id=<id>==&access_token=<access_token>" // Path == "/notifications/hub?id=<id>==&access_token=<access_token>"
// //
@@ -205,9 +203,7 @@ impl Handler for WSHandler {
let handler_insert = self.out.clone(); let handler_insert = self.out.clone();
let handler_update = self.out.clone(); let handler_update = self.out.clone();
self.users self.users.map.upsert(user_uuid, || vec![handler_insert], |ref mut v| v.push(handler_update));
.map
.upsert(user_uuid, || vec![handler_insert], |ref mut v| v.push(handler_update));
// Schedule a ping to keep the connection alive // Schedule a ping to keep the connection alive
self.out.timeout(PING_MS, PING) self.out.timeout(PING_MS, PING)
@@ -217,7 +213,11 @@ impl Handler for WSHandler {
if let Message::Text(text) = msg.clone() { if let Message::Text(text) = msg.clone() {
let json = &text[..text.len() - 1]; // Remove last char let json = &text[..text.len() - 1]; // Remove last char
if let Ok(InitialMessage { protocol, version }) = from_str::<InitialMessage>(json) { if let Ok(InitialMessage {
protocol,
version,
}) = from_str::<InitialMessage>(json)
{
if &protocol == "messagepack" && version == 1 { if &protocol == "messagepack" && version == 1 {
return self.out.send(&INITIAL_RESPONSE[..]); // Respond to initial message return self.out.send(&INITIAL_RESPONSE[..]); // Respond to initial message
} }
@@ -241,13 +241,13 @@ impl Handler for WSHandler {
} }
} }
struct WSFactory { struct WsFactory {
pub users: WebSocketUsers, pub users: WebSocketUsers,
} }
impl WSFactory { impl WsFactory {
pub fn init() -> Self { pub fn init() -> Self {
WSFactory { WsFactory {
users: WebSocketUsers { users: WebSocketUsers {
map: Arc::new(CHashMap::new()), map: Arc::new(CHashMap::new()),
}, },
@@ -255,11 +255,11 @@ impl WSFactory {
} }
} }
impl Factory for WSFactory { impl Factory for WsFactory {
type Handler = WSHandler; type Handler = WsHandler;
fn connection_made(&mut self, out: Sender) -> Self::Handler { fn connection_made(&mut self, out: Sender) -> Self::Handler {
WSHandler { WsHandler {
out, out,
user_uuid: None, user_uuid: None,
users: self.users.clone(), users: self.users.clone(),
@@ -296,10 +296,7 @@ impl WebSocketUsers {
// NOTE: The last modified date needs to be updated before calling these methods // NOTE: The last modified date needs to be updated before calling these methods
pub fn send_user_update(&self, ut: UpdateType, user: &User) { pub fn send_user_update(&self, ut: UpdateType, user: &User) {
let data = create_update( let data = create_update(
vec![ vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))],
("UserId".into(), user.uuid.clone().into()),
("Date".into(), serialize_date(user.updated_at)),
],
ut, ut,
); );
@@ -335,7 +332,7 @@ impl WebSocketUsers {
); );
for uuid in user_uuids { for uuid in user_uuids {
self.send_update(&uuid, &data).ok(); self.send_update(uuid, &data).ok();
} }
} }
} }
@@ -395,6 +392,10 @@ pub enum UpdateType {
LogOut = 11, LogOut = 11,
SyncSendCreate = 12,
SyncSendUpdate = 13,
SyncSendDelete = 14,
None = 100, None = 100,
} }
@@ -402,15 +403,17 @@ use rocket::State;
pub type Notify<'a> = State<'a, WebSocketUsers>; pub type Notify<'a> = State<'a, WebSocketUsers>;
pub fn start_notification_server() -> WebSocketUsers { pub fn start_notification_server() -> WebSocketUsers {
let factory = WSFactory::init(); let factory = WsFactory::init();
let users = factory.users.clone(); let users = factory.users.clone();
if CONFIG.websocket_enabled() { if CONFIG.websocket_enabled() {
thread::spawn(move || { thread::spawn(move || {
let mut settings = ws::Settings::default(); let settings = ws::Settings {
settings.max_connections = 500; max_connections: 500,
settings.queue_size = 2; queue_size: 2,
settings.panic_on_internal = false; panic_on_internal: false,
..Default::default()
};
ws::Builder::new() ws::Builder::new()
.with_settings(settings) .with_settings(settings)

View File

@@ -55,9 +55,9 @@ fn web_files(p: PathBuf) -> Cached<Option<NamedFile>> {
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).ok()) Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).ok())
} }
#[get("/attachments/<uuid>/<file..>")] #[get("/attachments/<uuid>/<file_id>")]
fn attachments(uuid: String, file: PathBuf) -> Option<NamedFile> { fn attachments(uuid: String, file_id: String) -> Option<NamedFile> {
NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file)).ok() NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file_id)).ok()
} }
#[get("/alive")] #[get("/alive")]
@@ -73,17 +73,22 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, Error> {
match filename.as_ref() { match filename.as_ref() {
"mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
"logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), "logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
"shield-white.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/shield-white.png"))),
"error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), "error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
"hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))), "hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
"vaultwarden-icon.png" => {
Ok(Content(ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png")))
}
"bootstrap.css" => Ok(Content(ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), "bootstrap.css" => Ok(Content(ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
"bootstrap-native.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap-native.js"))), "bootstrap-native.js" => {
"md5.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/md5.js"))), Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap-native.js")))
}
"identicon.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/identicon.js"))), "identicon.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/identicon.js"))),
"datatables.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), "datatables.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
"datatables.css" => Ok(Content(ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), "datatables.css" => Ok(Content(ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
"jquery-3.5.1.slim.js" => Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.5.1.slim.js"))), "jquery-3.6.0.slim.js" => {
Ok(Content(ContentType::JavaScript, include_bytes!("../static/scripts/jquery-3.6.0.slim.js")))
}
_ => err!(format!("Static file not found: {}", filename)), _ => err!(format!("Static file not found: {}", filename)),
} }
} }

View File

@@ -19,22 +19,34 @@ const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
pub static DEFAULT_VALIDITY: Lazy<Duration> = Lazy::new(|| Duration::hours(2)); pub static DEFAULT_VALIDITY: Lazy<Duration> = Lazy::new(|| Duration::hours(2));
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM)); static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: Lazy<Vec<u8>> = Lazy::new(|| match read_file(&CONFIG.private_rsa_key()) { static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
Ok(key) => key,
Err(e) => panic!("Error loading private RSA Key.\n Error: {}", e), static PRIVATE_RSA_KEY_VEC: Lazy<Vec<u8>> = Lazy::new(|| {
read_file(&CONFIG.private_rsa_key()).unwrap_or_else(|e| panic!("Error loading private RSA Key.\n{}", e))
}); });
static PUBLIC_RSA_KEY: Lazy<Vec<u8>> = Lazy::new(|| match read_file(&CONFIG.public_rsa_key()) { static PRIVATE_RSA_KEY: Lazy<EncodingKey> = Lazy::new(|| {
Ok(key) => key, EncodingKey::from_rsa_pem(&PRIVATE_RSA_KEY_VEC).unwrap_or_else(|e| panic!("Error decoding private RSA Key.\n{}", e))
Err(e) => panic!("Error loading public RSA Key.\n Error: {}", e), });
static PUBLIC_RSA_KEY_VEC: Lazy<Vec<u8>> = Lazy::new(|| {
read_file(&CONFIG.public_rsa_key()).unwrap_or_else(|e| panic!("Error loading public RSA Key.\n{}", e))
});
static PUBLIC_RSA_KEY: Lazy<DecodingKey> = Lazy::new(|| {
DecodingKey::from_rsa_pem(&PUBLIC_RSA_KEY_VEC).unwrap_or_else(|e| panic!("Error decoding public RSA Key.\n{}", e))
}); });
pub fn load_keys() {
Lazy::force(&PRIVATE_RSA_KEY);
Lazy::force(&PUBLIC_RSA_KEY);
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String { pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, &EncodingKey::from_rsa_der(&PRIVATE_RSA_KEY)) { match jsonwebtoken::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
Ok(token) => token, Ok(token) => token,
Err(e) => panic!("Error encoding jwt {}", e), Err(e) => panic!("Error encoding jwt {}", e),
} }
@@ -52,34 +64,35 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
}; };
let token = token.replace(char::is_whitespace, ""); let token = token.replace(char::is_whitespace, "");
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation).map(|d| d.claims).map_res("Error decoding JWT")
jsonwebtoken::decode(&token, &DecodingKey::from_rsa_der(&PUBLIC_RSA_KEY), &validation)
.map(|d| d.claims)
.map_res("Error decoding JWT")
} }
pub fn decode_login(token: &str) -> Result<LoginJWTClaims, Error> { pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
} }
pub fn decode_invite(token: &str) -> Result<InviteJWTClaims, Error> { pub fn decode_invite(token: &str) -> Result<InviteJwtClaims, Error> {
decode_jwt(token, JWT_INVITE_ISSUER.to_string()) decode_jwt(token, JWT_INVITE_ISSUER.to_string())
} }
pub fn decode_delete(token: &str) -> Result<DeleteJWTClaims, Error> { pub fn decode_delete(token: &str) -> Result<BasicJwtClaims, Error> {
decode_jwt(token, JWT_DELETE_ISSUER.to_string()) decode_jwt(token, JWT_DELETE_ISSUER.to_string())
} }
pub fn decode_verify_email(token: &str) -> Result<VerifyEmailJWTClaims, Error> { pub fn decode_verify_email(token: &str) -> Result<BasicJwtClaims, Error> {
decode_jwt(token, JWT_VERIFYEMAIL_ISSUER.to_string()) decode_jwt(token, JWT_VERIFYEMAIL_ISSUER.to_string())
} }
pub fn decode_admin(token: &str) -> Result<AdminJWTClaims, Error> { pub fn decode_admin(token: &str) -> Result<BasicJwtClaims, Error> {
decode_jwt(token, JWT_ADMIN_ISSUER.to_string()) decode_jwt(token, JWT_ADMIN_ISSUER.to_string())
} }
pub fn decode_send(token: &str) -> Result<BasicJwtClaims, Error> {
decode_jwt(token, JWT_SEND_ISSUER.to_string())
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LoginJWTClaims { pub struct LoginJwtClaims {
// Not before // Not before
pub nbf: i64, pub nbf: i64,
// Expiration time // Expiration time
@@ -110,7 +123,7 @@ pub struct LoginJWTClaims {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct InviteJWTClaims { pub struct InviteJwtClaims {
// Not before // Not before
pub nbf: i64, pub nbf: i64,
// Expiration time // Expiration time
@@ -132,9 +145,9 @@ pub fn generate_invite_claims(
org_id: Option<String>, org_id: Option<String>,
user_org_id: Option<String>, user_org_id: Option<String>,
invited_by_email: Option<String>, invited_by_email: Option<String>,
) -> InviteJWTClaims { ) -> InviteJwtClaims {
let time_now = Utc::now().naive_utc(); let time_now = Utc::now().naive_utc();
InviteJWTClaims { InviteJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(), exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_INVITE_ISSUER.to_string(), iss: JWT_INVITE_ISSUER.to_string(),
@@ -147,7 +160,7 @@ pub fn generate_invite_claims(
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct DeleteJWTClaims { pub struct BasicJwtClaims {
// Not before // Not before
pub nbf: i64, pub nbf: i64,
// Expiration time // Expiration time
@@ -158,9 +171,9 @@ pub struct DeleteJWTClaims {
pub sub: String, pub sub: String,
} }
pub fn generate_delete_claims(uuid: String) -> DeleteJWTClaims { pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now().naive_utc(); let time_now = Utc::now().naive_utc();
DeleteJWTClaims { BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(), exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_DELETE_ISSUER.to_string(), iss: JWT_DELETE_ISSUER.to_string(),
@@ -168,21 +181,9 @@ pub fn generate_delete_claims(uuid: String) -> DeleteJWTClaims {
} }
} }
#[derive(Debug, Serialize, Deserialize)] pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims {
pub struct VerifyEmailJWTClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: String,
}
pub fn generate_verify_email_claims(uuid: String) -> DeleteJWTClaims {
let time_now = Utc::now().naive_utc(); let time_now = Utc::now().naive_utc();
DeleteJWTClaims { BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(), exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_VERIFYEMAIL_ISSUER.to_string(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
@@ -190,21 +191,9 @@ pub fn generate_verify_email_claims(uuid: String) -> DeleteJWTClaims {
} }
} }
#[derive(Debug, Serialize, Deserialize)] pub fn generate_admin_claims() -> BasicJwtClaims {
pub struct AdminJWTClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: String,
}
pub fn generate_admin_claims() -> AdminJWTClaims {
let time_now = Utc::now().naive_utc(); let time_now = Utc::now().naive_utc();
AdminJWTClaims { BasicJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + Duration::minutes(20)).timestamp(), exp: (time_now + Duration::minutes(20)).timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(), iss: JWT_ADMIN_ISSUER.to_string(),
@@ -212,25 +201,31 @@ pub fn generate_admin_claims() -> AdminJWTClaims {
} }
} }
pub fn generate_send_claims(send_id: &str, file_id: &str) -> BasicJwtClaims {
let time_now = Utc::now().naive_utc();
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + Duration::minutes(2)).timestamp(),
iss: JWT_SEND_ISSUER.to_string(),
sub: format!("{}/{}", send_id, file_id),
}
}
// //
// Bearer token authentication // Bearer token authentication
// //
use rocket::{ use rocket::request::{FromRequest, Outcome, Request};
request::{FromRequest, Request, Outcome},
};
use crate::db::{ use crate::db::{
models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization}, models::{CollectionUser, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException},
DbConn, DbConn,
}; };
pub struct Headers { pub struct Host {
pub host: String, pub host: String,
pub device: Device,
pub user: User,
} }
impl<'a, 'r> FromRequest<'a, 'r> for Headers { impl<'a, 'r> FromRequest<'a, 'r> for Host {
type Error = &'static str; type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> { fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
@@ -264,6 +259,30 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
format!("{}://{}", protocol, host) format!("{}://{}", protocol, host)
}; };
Outcome::Success(Host {
host,
})
}
}
pub struct Headers {
pub host: String,
pub device: Device,
pub user: User,
}
impl<'a, 'r> FromRequest<'a, 'r> for Headers {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
let headers = request.headers();
let host = match Host::from_request(request) {
Outcome::Forward(_) => return Outcome::Forward(()),
Outcome::Failure(f) => return Outcome::Failure(f),
Outcome::Success(host) => host.host,
};
// Get access_token // Get access_token
let access_token: &str = match headers.get_one("Authorization") { let access_token: &str = match headers.get_one("Authorization") {
Some(a) => match a.rsplit("Bearer ").next() { Some(a) => match a.rsplit("Bearer ").next() {
@@ -298,10 +317,30 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
}; };
if user.security_stamp != claims.sstamp { if user.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp") 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) {
Some(name) => name,
_ => err_handler!("Error getting current route for stamp exception"),
};
// Check if both match, if not this route is not allowed with the current security stamp.
if stamp_exception.route != current_route {
err_handler!("Invalid security stamp: Current route and exception route do not match")
} else if stamp_exception.security_stamp != claims.sstamp {
err_handler!("Invalid security stamp for matched stamp exception")
}
} else {
err_handler!("Invalid security stamp")
}
} }
Outcome::Success(Headers { host, device, user }) Outcome::Success(Headers {
host,
device,
user,
})
} }
} }
@@ -310,11 +349,13 @@ pub struct OrgHeaders {
pub device: Device, pub device: Device,
pub user: User, pub user: User,
pub org_user_type: UserOrgType, pub org_user_type: UserOrgType,
pub org_user: UserOrganization,
pub org_id: String,
} }
// org_id is usually the second param ("/organizations/<org_id>") // org_id is usually the second path param ("/organizations/<org_id>"),
// But there are cases where it is located in a query value. // but there are cases where it is a query value.
// First check the param, if this is not a valid uuid, we will try the query value. // First check the path, if this is not a valid uuid, try the query values.
fn get_org_id(request: &Request) -> Option<String> { fn get_org_id(request: &Request) -> Option<String> {
if let Some(Ok(org_id)) = request.get_param::<String>(1) { if let Some(Ok(org_id)) = request.get_param::<String>(1) {
if uuid::Uuid::parse_str(&org_id).is_ok() { if uuid::Uuid::parse_str(&org_id).is_ok() {
@@ -370,6 +411,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
err_handler!("Unknown user type in the database") err_handler!("Unknown user type in the database")
} }
}, },
org_user,
org_id,
}) })
} }
_ => err_handler!("Error getting the organization id"), _ => err_handler!("Error getting the organization id"),
@@ -409,12 +452,137 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
} }
} }
impl Into<Headers> for AdminHeaders { impl From<AdminHeaders> for Headers {
fn into(self) -> Headers { fn from(h: AdminHeaders) -> Headers {
Headers { Headers {
host: self.host, host: h.host,
device: self.device, device: h.device,
user: self.user, user: h.user,
}
}
}
// 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> {
if let Some(Ok(col_id)) = request.get_param::<String>(3) {
if uuid::Uuid::parse_str(&col_id).is_ok() {
return Some(col_id);
}
}
if let Some(Ok(col_id)) = request.get_query_value::<String>("collectionId") {
if uuid::Uuid::parse_str(&col_id).is_ok() {
return Some(col_id);
}
}
None
}
/// The ManagerHeaders are used to check if you are at least a Manager
/// and have access to the specific collection provided via the <col_id>/collections/collectionId.
/// This does strict checking on the collection_id, ManagerHeadersLoose does not.
pub struct ManagerHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub org_user_type: UserOrgType,
}
impl<'a, 'r> FromRequest<'a, 'r> for ManagerHeaders {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
match request.guard::<OrgHeaders>() {
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
if headers.org_user_type >= UserOrgType::Manager {
match get_col_id(request) {
Some(col_id) => {
let conn = match request.guard::<DbConn>() {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB"),
};
if !headers.org_user.has_full_access() {
match CollectionUser::find_by_collection_and_user(
&col_id,
&headers.org_user.user_uuid,
&conn,
) {
Some(_) => (),
None => err_handler!("The current user isn't a manager for this collection"),
}
}
}
_ => err_handler!("Error getting the collection id"),
}
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
org_user_type: headers.org_user_type,
})
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
}
}
}
}
}
impl From<ManagerHeaders> for Headers {
fn from(h: ManagerHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
}
}
}
/// The ManagerHeadersLoose is used when you at least need to be a Manager,
/// but there is no collection_id sent with the request (either in the path or as form data).
pub struct ManagerHeadersLoose {
pub host: String,
pub device: Device,
pub user: User,
pub org_user_type: UserOrgType,
}
impl<'a, 'r> FromRequest<'a, 'r> for ManagerHeadersLoose {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> Outcome<Self, Self::Error> {
match request.guard::<OrgHeaders>() {
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
if headers.org_user_type >= UserOrgType::Manager {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
org_user_type: headers.org_user_type,
})
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
}
}
}
}
}
impl From<ManagerHeadersLoose> for Headers {
fn from(h: ManagerHeadersLoose) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
} }
} }
} }
@@ -474,10 +642,10 @@ impl<'a, 'r> FromRequest<'a, 'r> for ClientIp {
None None
}; };
let ip = ip let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
.or_else(|| req.remote().map(|r| r.ip()))
.unwrap_or_else(|| "0.0.0.0".parse().unwrap());
Outcome::Success(ClientIp { ip }) Outcome::Success(ClientIp {
ip,
})
} }
} }

View File

@@ -2,6 +2,7 @@ use std::process::exit;
use std::sync::RwLock; use std::sync::RwLock;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::Url; use reqwest::Url;
use crate::{ use crate::{
@@ -22,6 +23,21 @@ pub static CONFIG: Lazy<Config> = Lazy::new(|| {
}) })
}); });
static PRIVACY_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\w]").unwrap());
const PRIVACY_CONFIG: &[&str] = &[
"allowed_iframe_ancestors",
"database_url",
"domain_origin",
"domain_path",
"domain",
"helo_name",
"org_creation_users",
"signups_domains_whitelist",
"smtp_from",
"smtp_host",
"smtp_username",
];
pub type Pass = String; pub type Pass = String;
macro_rules! make_config { macro_rules! make_config {
@@ -41,6 +57,8 @@ macro_rules! make_config {
_env: ConfigBuilder, _env: ConfigBuilder,
_usr: ConfigBuilder, _usr: ConfigBuilder,
_overrides: Vec<String>,
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -52,8 +70,30 @@ macro_rules! make_config {
} }
impl ConfigBuilder { impl ConfigBuilder {
#[allow(clippy::field_reassign_with_default)]
fn from_env() -> Self { fn from_env() -> Self {
dotenv::from_path(".env").ok(); match dotenv::from_path(".env") {
Ok(_) => (),
Err(e) => match e {
dotenv::Error::LineParse(msg, pos) => {
panic!("Error loading the .env file:\nNear {:?} on position {}\nPlease fix and restart!\n", msg, pos);
},
dotenv::Error::Io(ioerr) => match ioerr.kind() {
std::io::ErrorKind::NotFound => {
println!("[INFO] No .env file found.\n");
},
std::io::ErrorKind::PermissionDenied => {
println!("[WARNING] Permission Denied while trying to read the .env file!\n");
},
_ => {
println!("[WARNING] Reading the .env file failed:\n{:?}\n", ioerr);
}
},
_ => {
println!("[WARNING] Reading the .env file failed:\n{:?}\n", e);
}
}
};
let mut builder = ConfigBuilder::default(); let mut builder = ConfigBuilder::default();
$($( $($(
@@ -71,8 +111,7 @@ macro_rules! make_config {
/// Merges the values of both builders into a new builder. /// Merges the values of both builders into a new builder.
/// If both have the same element, `other` wins. /// If both have the same element, `other` wins.
fn merge(&self, other: &Self, show_overrides: bool) -> Self { fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<String>) -> Self {
let mut overrides = Vec::new();
let mut builder = self.clone(); let mut builder = self.clone();
$($( $($(
if let v @Some(_) = &other.$name { if let v @Some(_) = &other.$name {
@@ -134,9 +173,9 @@ macro_rules! make_config {
)+)+ )+)+
pub fn prepare_json(&self) -> serde_json::Value { pub fn prepare_json(&self) -> serde_json::Value {
let (def, cfg) = { let (def, cfg, overriden) = {
let inner = &self.inner.read().unwrap(); let inner = &self.inner.read().unwrap();
(inner._env.build(), inner.config.clone()) (inner._env.build(), inner.config.clone(), inner._overrides.clone())
}; };
fn _get_form_type(rust_type: &str) -> &'static str { fn _get_form_type(rust_type: &str) -> &'static str {
@@ -168,12 +207,51 @@ macro_rules! make_config {
"default": def.$name, "default": def.$name,
"type": _get_form_type(stringify!($ty)), "type": _get_form_type(stringify!($ty)),
"doc": _get_doc(concat!($($doc),+)), "doc": _get_doc(concat!($($doc),+)),
"overridden": overriden.contains(&stringify!($name).to_uppercase()),
}, )+ }, )+
]}, )+ ]) ]}, )+ ])
} }
pub fn get_support_json(&self) -> serde_json::Value {
let cfg = {
let inner = &self.inner.read().unwrap();
inner.config.clone()
};
json!({ $($(
stringify!($name): make_config!{ @supportstr $name, cfg.$name, $ty, $none_action },
)+)+ })
}
pub fn get_overrides(&self) -> Vec<String> {
let overrides = {
let inner = &self.inner.read().unwrap();
inner._overrides.clone()
};
overrides
}
} }
}; };
// Support string print
( @supportstr $name:ident, $value:expr, Pass, option ) => { $value.as_ref().map(|_| String::from("***")) }; // Optional pass, we map to an Option<String> with "***"
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { String::from("***") }; // Required pass, we return "***"
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { // Optional other value, we return as is or convert to string to apply the privacy config
if PRIVACY_CONFIG.contains(&stringify!($name)) {
json!($value.as_ref().map(|x| PRIVACY_REGEX.replace_all(&x.to_string(), "${1}*").to_string()))
} else {
json!($value)
}
};
( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { // Required other value, we return as is or convert to string to apply the privacy config
if PRIVACY_CONFIG.contains(&stringify!($name)) {
json!(PRIVACY_REGEX.replace_all(&$value.to_string(), "${1}*").to_string())
} else {
json!($value)
}
};
// Group or empty string // Group or empty string
( @show ) => { "" }; ( @show ) => { "" };
( @show $lit:literal ) => { $lit }; ( @show $lit:literal ) => { $lit };
@@ -228,6 +306,8 @@ make_config! {
icon_cache_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "icon_cache"); icon_cache_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "icon_cache");
/// Attachments folder /// Attachments folder
attachments_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "attachments"); attachments_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "attachments");
/// Sends folder
sends_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "sends");
/// Templates folder /// Templates folder
templates_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "templates"); templates_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "templates");
/// Session JWT key /// Session JWT key
@@ -243,6 +323,17 @@ make_config! {
/// Websocket port /// Websocket port
websocket_port: u16, false, def, 3012; websocket_port: u16, false, def, 3012;
}, },
jobs {
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
/// Set to 0 to globally disable scheduled jobs.
job_poll_interval_ms: u64, false, def, 30_000;
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
/// Defaults to hourly. Set blank to disable this job.
send_purge_schedule: String, false, def, "0 5 * * * *".to_string();
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
/// Defaults to daily. Set blank to disable this job.
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
},
/// General settings /// General settings
settings { settings {
@@ -258,6 +349,10 @@ make_config! {
/// Enable web vault /// Enable web vault
web_vault_enabled: bool, false, def, true; web_vault_enabled: bool, false, def, true;
/// Allow Sends |> Controls whether users are allowed to create Bitwarden Sends.
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
sends_allowed: bool, true, def, true;
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key /// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
hibp_api_key: Pass, true, option; hibp_api_key: Pass, true, option;
@@ -266,11 +361,16 @@ make_config! {
/// Per-organization attachment limit (KB) |> Limit in kilobytes for an organization attachments, once the limit is exceeded it won't be possible to upload more /// Per-organization attachment limit (KB) |> Limit in kilobytes for an organization attachments, once the limit is exceeded it won't be possible to upload more
org_attachment_limit: i64, true, option; org_attachment_limit: i64, true, option;
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
/// sure to inform all users of any changes to this setting.
trash_auto_delete_days: i64, true, option;
/// Disable icon downloads |> Set to true to disable icon downloading, this would still serve icons from /// Disable icon downloads |> Set to true to disable icon downloading, this would still serve icons from
/// $ICON_CACHE_FOLDER, but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0, /// $ICON_CACHE_FOLDER, but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0,
/// otherwise it will delete them and they won't be downloaded again. /// otherwise it will delete them and they won't be downloaded again.
disable_icon_download: bool, true, def, false; disable_icon_download: bool, true, def, false;
/// Allow new signups |> Controls whether new users can register. Users can be invited by the bitwarden_rs admin even if this is disabled /// 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; 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. This will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false; signups_verify: bool, true, def, false;
@@ -296,7 +396,7 @@ make_config! {
admin_token: Pass, true, option; admin_token: Pass, true, option;
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
invitation_org_name: String, true, def, "Bitwarden_RS".to_string(); invitation_org_name: String, true, def, "Vaultwarden".to_string();
}, },
/// Advanced settings /// Advanced settings
@@ -345,7 +445,7 @@ make_config! {
/// Log level /// Log level
log_level: String, false, def, "Info".to_string(); log_level: String, false, def, "Info".to_string();
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using bitwarden_rs on some exotic filesystems, /// 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. /// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting.
enable_db_wal: bool, false, def, true; enable_db_wal: bool, false, def, true;
@@ -388,29 +488,35 @@ make_config! {
/// SMTP Email Settings /// SMTP Email Settings
smtp: _enable_smtp { smtp: _enable_smtp {
/// Enabled /// Enabled
_enable_smtp: bool, true, def, true; _enable_smtp: bool, true, def, true;
/// Host /// Host
smtp_host: String, true, option; smtp_host: String, true, option;
/// Enable Secure SMTP |> (Explicit) - Enabling this by default would use STARTTLS (Standard ports 587 or 25) /// Enable Secure SMTP |> (Explicit) - Enabling this by default would use STARTTLS (Standard ports 587 or 25)
smtp_ssl: bool, true, def, true; smtp_ssl: bool, true, def, true;
/// Force TLS |> (Implicit) - Enabling this would force the use of an SSL/TLS connection, instead of upgrading an insecure one with STARTTLS (Standard port 465) /// Force TLS |> (Implicit) - Enabling this would force the use of an SSL/TLS connection, instead of upgrading an insecure one with STARTTLS (Standard port 465)
smtp_explicit_tls: bool, true, def, false; smtp_explicit_tls: bool, true, def, false;
/// Port /// Port
smtp_port: u16, true, auto, |c| if c.smtp_explicit_tls {465} else if c.smtp_ssl {587} else {25}; smtp_port: u16, true, auto, |c| if c.smtp_explicit_tls {465} else if c.smtp_ssl {587} else {25};
/// From Address /// From Address
smtp_from: String, true, def, String::new(); smtp_from: String, true, def, String::new();
/// From Name /// From Name
smtp_from_name: String, true, def, "Bitwarden_RS".to_string(); smtp_from_name: String, true, def, "Vaultwarden".to_string();
/// Username /// Username
smtp_username: String, true, option; smtp_username: String, true, option;
/// Password /// Password
smtp_password: Pass, true, option; smtp_password: Pass, true, option;
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
smtp_auth_mechanism: String, true, option; smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u64, true, def, 15; smtp_timeout: u64, true, def, 15;
/// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters /// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option; helo_name: String, true, option;
/// 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!
smtp_accept_invalid_certs: bool, true, def, false;
/// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks!
smtp_accept_invalid_hostnames: bool, true, def, false;
}, },
/// Email 2FA Settings /// Email 2FA Settings
@@ -427,21 +533,19 @@ make_config! {
} }
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
// Validate connection URL is valid and DB feature is enabled // Validate connection URL is valid and DB feature is enabled
DbConnType::from_url(&cfg.database_url)?; DbConnType::from_url(&cfg.database_url)?;
let limit = 256; let limit = 256;
if cfg.database_max_conns < 1 || cfg.database_max_conns > limit { if cfg.database_max_conns < 1 || cfg.database_max_conns > limit {
err!(format!( err!(format!("`DATABASE_MAX_CONNS` contains an invalid value. Ensure it is between 1 and {}.", limit,));
"`DATABASE_MAX_CONNS` contains an invalid value. Ensure it is between 1 and {}.",
limit,
));
} }
let dom = cfg.domain.to_lowercase(); let dom = cfg.domain.to_lowercase();
if !dom.starts_with("http://") && !dom.starts_with("https://") { if !dom.starts_with("http://") && !dom.starts_with("https://") {
err!("DOMAIN variable needs to contain the protocol (http, https). Use 'http[s]://bw.example.com' instead of 'bw.example.com'"); err!(
"DOMAIN variable needs to contain the protocol (http, https). Use 'http[s]://bw.example.com' instead of 'bw.example.com'"
);
} }
let whitelist = &cfg.signups_domains_whitelist; let whitelist = &cfg.signups_domains_whitelist;
@@ -450,10 +554,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
} }
let org_creation_users = cfg.org_creation_users.trim().to_lowercase(); let org_creation_users = cfg.org_creation_users.trim().to_lowercase();
if !(org_creation_users.is_empty() || org_creation_users == "all" || org_creation_users == "none") { if !(org_creation_users.is_empty() || org_creation_users == "all" || org_creation_users == "none")
if org_creation_users.split(',').any(|u| !u.contains('@')) { && org_creation_users.split(',').any(|u| !u.contains('@'))
err!("`ORG_CREATION_USERS` contains invalid email addresses"); {
} err!("`ORG_CREATION_USERS` contains invalid email addresses");
} }
if let Some(ref token) = cfg.admin_token { if let Some(ref token) = cfg.admin_token {
@@ -479,6 +583,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support") err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support")
} }
if cfg.smtp_host.is_some() && !cfg.smtp_from.contains('@') {
err!("SMTP_FROM does not contain a mandatory @ sign")
}
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication") err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication")
} }
@@ -496,6 +604,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
} }
} }
// Check if the icon blacklist regex is valid
if let Some(ref r) = cfg.icon_blacklist_regex {
let validate_regex = Regex::new(r);
match validate_regex {
Ok(_) => (),
Err(e) => err!(format!("`ICON_BLACKLIST_REGEX` is invalid: {:#?}", e)),
}
}
Ok(()) Ok(())
} }
@@ -529,14 +646,21 @@ impl Config {
let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default(); let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default();
// Create merged config, config file overwrites env // Create merged config, config file overwrites env
let builder = _env.merge(&_usr, true); let mut _overrides = Vec::new();
let builder = _env.merge(&_usr, true, &mut _overrides);
// Fill any missing with defaults // Fill any missing with defaults
let config = builder.build(); let config = builder.build();
validate_config(&config)?; validate_config(&config)?;
Ok(Config { Ok(Config {
inner: RwLock::new(Inner { templates: load_templates(&config.templates_folder), config, _env, _usr }), inner: RwLock::new(Inner {
templates: load_templates(&config.templates_folder),
config,
_env,
_usr,
_overrides,
}),
}) })
} }
@@ -551,9 +675,10 @@ impl Config {
let config_str = serde_json::to_string_pretty(&builder)?; let config_str = serde_json::to_string_pretty(&builder)?;
// Prepare the combined config // Prepare the combined config
let mut overrides = Vec::new();
let config = { let config = {
let env = &self.inner.read().unwrap()._env; let env = &self.inner.read().unwrap()._env;
env.merge(&builder, false).build() env.merge(&builder, false, &mut overrides).build()
}; };
validate_config(&config)?; validate_config(&config)?;
@@ -562,6 +687,7 @@ impl Config {
let mut writer = self.inner.write().unwrap(); let mut writer = self.inner.write().unwrap();
writer.config = config; writer.config = config;
writer._usr = builder; writer._usr = builder;
writer._overrides = overrides;
} }
//Save to file //Save to file
@@ -575,7 +701,8 @@ impl Config {
pub fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { pub fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = { let builder = {
let usr = &self.inner.read().unwrap()._usr; let usr = &self.inner.read().unwrap()._usr;
usr.merge(&other, false) let mut _overrides = Vec::new();
usr.merge(&other, false, &mut _overrides)
}; };
self.update_config(builder) self.update_config(builder)
} }
@@ -609,7 +736,7 @@ impl Config {
/// Tests whether the specified user is allowed to create an organization. /// Tests whether the specified user is allowed to create an organization.
pub fn is_org_creation_allowed(&self, email: &str) -> bool { pub fn is_org_creation_allowed(&self, email: &str) -> bool {
let users = self.org_creation_users(); let users = self.org_creation_users();
if users == "" || users == "all" { if users.is_empty() || users == "all" {
true true
} else if users == "none" { } else if users == "none" {
false false
@@ -636,19 +763,17 @@ impl Config {
let mut writer = self.inner.write().unwrap(); let mut writer = self.inner.write().unwrap();
writer.config = config; writer.config = config;
writer._usr = usr; writer._usr = usr;
writer._overrides = Vec::new();
} }
Ok(()) Ok(())
} }
pub fn private_rsa_key(&self) -> String { pub fn private_rsa_key(&self) -> String {
format!("{}.der", CONFIG.rsa_key_filename())
}
pub fn private_rsa_key_pem(&self) -> String {
format!("{}.pem", CONFIG.rsa_key_filename()) format!("{}.pem", CONFIG.rsa_key_filename())
} }
pub fn public_rsa_key(&self) -> String { pub fn public_rsa_key(&self) -> String {
format!("{}.pub.der", CONFIG.rsa_key_filename()) format!("{}.pub.pem", CONFIG.rsa_key_filename())
} }
pub fn mail_enabled(&self) -> bool { pub fn mail_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config; let inner = &self.inner.read().unwrap().config;
@@ -663,8 +788,10 @@ impl Config {
let akey_s = data_encoding::BASE64.encode(&akey); let akey_s = data_encoding::BASE64.encode(&akey);
// Save the new value // Save the new value
let mut builder = ConfigBuilder::default(); let builder = ConfigBuilder {
builder._duo_akey = Some(akey_s.clone()); _duo_akey: Some(akey_s.clone()),
..Default::default()
};
self.update_config_partial(builder).ok(); self.update_config_partial(builder).ok();
akey_s akey_s
@@ -719,6 +846,10 @@ where
} }
// First register default templates here // First register default templates here
reg!("email/email_header");
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/change_email", ".html"); reg!("email/change_email", ".html");
reg!("email/delete_account", ".html"); reg!("email/delete_account", ".html");
reg!("email/invite_accepted", ".html"); reg!("email/invite_accepted", ".html");
@@ -755,9 +886,7 @@ fn case_helper<'reg, 'rc>(
rc: &mut RenderContext<'reg, 'rc>, rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output, out: &mut dyn Output,
) -> HelperResult { ) -> HelperResult {
let param = h let param = h.param(0).ok_or_else(|| RenderError::new("Param not found for helper \"case\""))?;
.param(0)
.ok_or_else(|| RenderError::new("Param not found for helper \"case\""))?;
let value = param.value().clone(); let value = param.value().clone();
if h.params().iter().skip(1).any(|x| x.value() == &value) { if h.params().iter().skip(1).any(|x| x.value() == &value) {
@@ -774,18 +903,18 @@ fn js_escape_helper<'reg, 'rc>(
_rc: &mut RenderContext<'reg, 'rc>, _rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output, out: &mut dyn Output,
) -> HelperResult { ) -> HelperResult {
let param = h let param = h.param(0).ok_or_else(|| RenderError::new("Param not found for helper \"js_escape\""))?;
.param(0)
.ok_or_else(|| RenderError::new("Param not found for helper \"js_escape\""))?;
let value = param let no_quote = h.param(1).is_some();
.value()
.as_str()
.ok_or_else(|| RenderError::new("Param for helper \"js_escape\" is not a String"))?;
let escaped_value = value.replace('\\', "").replace('\'', "\\x22").replace('\"', "\\x27"); let value =
let quoted_value = format!("&quot;{}&quot;", escaped_value); param.value().as_str().ok_or_else(|| RenderError::new("Param for helper \"js_escape\" is not a String"))?;
out.write(&quoted_value)?; let mut escaped_value = value.replace('\\', "").replace('\'', "\\x22").replace('\"', "\\x27");
if !no_quote {
escaped_value = format!("&quot;{}&quot;", escaped_value);
}
out.write(&escaped_value)?;
Ok(()) Ok(())
} }

View File

@@ -3,6 +3,7 @@
// //
use std::num::NonZeroU32; use std::num::NonZeroU32;
use data_encoding::HEXLOWER;
use ring::{digest, hmac, pbkdf2}; use ring::{digest, hmac, pbkdf2};
use crate::error::Error; use crate::error::Error;
@@ -28,8 +29,6 @@ pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterati
// HMAC // HMAC
// //
pub fn hmac_sign(key: &str, data: &str) -> String { pub fn hmac_sign(key: &str, data: &str) -> String {
use data_encoding::HEXLOWER;
let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes()); let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes());
let signature = hmac::sign(&key, data.as_bytes()); let signature = hmac::sign(&key, data.as_bytes());
@@ -47,13 +46,25 @@ pub fn get_random_64() -> Vec<u8> {
pub fn get_random(mut array: Vec<u8>) -> Vec<u8> { pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
SystemRandom::new() SystemRandom::new().fill(&mut array).expect("Error generating random values");
.fill(&mut array)
.expect("Error generating random values");
array array
} }
pub fn generate_id(num_bytes: usize) -> String {
HEXLOWER.encode(&get_random(vec![0; num_bytes]))
}
pub fn generate_send_id() -> String {
// Send IDs are globally scoped, so make them longer to avoid collisions.
generate_id(32) // 256 bits
}
pub fn generate_attachment_id() -> String {
// Attachment IDs are scoped to a cipher, so they can be smaller.
generate_id(10) // 80 bits
}
pub fn generate_token(token_size: u32) -> Result<String, Error> { pub fn generate_token(token_size: u32) -> Result<String, Error> {
// A u64 can represent all whole numbers up to 19 digits long. // A u64 can represent all whole numbers up to 19 digits long.
if token_size > 19 { if token_size > 19 {
@@ -67,7 +78,7 @@ pub fn generate_token(token_size: u32) -> Result<String, Error> {
// token of fixed width, left-padding with 0 as needed. // token of fixed width, left-padding with 0 as needed.
use rand::{thread_rng, Rng}; use rand::{thread_rng, Rng};
let mut rng = thread_rng(); let mut rng = thread_rng();
let number: u64 = rng.gen_range(low, high); let number: u64 = rng.gen_range(low..high);
let token = format!("{:0size$}", number, size = token_size as usize); let token = format!("{:0size$}", number, size = token_size as usize);
Ok(token) Ok(token)

View File

@@ -1,6 +1,3 @@
use std::process::Command;
use chrono::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
use rocket::{ use rocket::{
http::Status, http::Status,
@@ -25,7 +22,6 @@ pub mod __mysql_schema;
#[path = "schemas/postgresql/schema.rs"] #[path = "schemas/postgresql/schema.rs"]
pub mod __postgresql_schema; pub mod __postgresql_schema;
// This is used to generate the main DbConn and DbPool enums, which contain one variant for each database supported // This is used to generate the main DbConn and DbPool enums, which contain one variant for each database supported
macro_rules! generate_connections { macro_rules! generate_connections {
( $( $name:ident: $ty:ty ),+ ) => { ( $( $name:ident: $ty:ty ),+ ) => {
@@ -37,6 +33,7 @@ macro_rules! generate_connections {
pub enum DbConn { $( #[cfg($name)] $name(PooledConnection<ConnectionManager< $ty >>), )+ } pub enum DbConn { $( #[cfg($name)] $name(PooledConnection<ConnectionManager< $ty >>), )+ }
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
#[derive(Clone)]
pub enum DbPool { $( #[cfg($name)] $name(Pool<ConnectionManager< $ty >>), )+ } pub enum DbPool { $( #[cfg($name)] $name(Pool<ConnectionManager< $ty >>), )+ }
impl DbPool { impl DbPool {
@@ -109,7 +106,6 @@ impl DbConnType {
} }
} }
#[macro_export] #[macro_export]
macro_rules! db_run { macro_rules! db_run {
// Same for all dbs // Same for all dbs
@@ -118,32 +114,68 @@ macro_rules! db_run {
}; };
// Different code for each db // Different code for each db
( $conn:ident: $( $($db:ident),+ $body:block )+ ) => { ( $conn:ident: $( $($db:ident),+ $body:block )+ ) => {{
#[allow(unused)] use diesel::prelude::*; #[allow(unused)] use diesel::prelude::*;
match $conn { match $conn {
$($( $($(
#[cfg($db)] #[cfg($db)]
crate::db::DbConn::$db(ref $conn) => { crate::db::DbConn::$db(ref $conn) => {
paste::paste! { paste::paste! {
#[allow(unused)] use crate::db::[<__ $db _schema>]::{self as schema, *}; #[allow(unused)] use crate::db::[<__ $db _schema>]::{self as schema, *};
#[allow(unused)] use [<__ $db _model>]::*; #[allow(unused)] use [<__ $db _model>]::*;
#[allow(unused)] use crate::db::FromDb; #[allow(unused)] use crate::db::FromDb;
} }
$body $body
}, },
)+)+ )+)+
}}
};
// Same for all dbs
( @raw $conn:ident: $body:block ) => {
db_run! { @raw $conn: sqlite, mysql, postgresql $body }
};
// Different code for each db
( @raw $conn:ident: $( $($db:ident),+ $body:block )+ ) => {
#[allow(unused)] use diesel::prelude::*;
#[allow(unused_variables)]
match $conn {
$($(
#[cfg($db)]
crate::db::DbConn::$db(ref $conn) => {
$body
},
)+)+
} }
}; };
} }
pub trait FromDb { pub trait FromDb {
type Output; type Output;
#[allow(clippy::wrong_self_convention)] #[allow(clippy::wrong_self_convention)]
fn from_db(self) -> Self::Output; fn from_db(self) -> Self::Output;
} }
// For each struct eg. Cipher, we create a CipherDb inside a module named __$db_model (where $db is sqlite, mysql or postgresql), 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()
}
}
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)
}
}
// For each struct eg. Cipher, we create a CipherDb inside a module named __$db_model (where $db is sqlite, mysql or postgresql),
// to implement the Diesel traits. We also provide methods to convert between them and the basic structs. Later, that module will be auto imported when using db_run! // to implement the Diesel traits. We also provide methods to convert between them and the basic structs. Later, that module will be auto imported when using db_run!
#[macro_export] #[macro_export]
macro_rules! db_object { macro_rules! db_object {
@@ -153,10 +185,10 @@ macro_rules! db_object {
$( $( #[$field_attr:meta] )* $vis:vis $field:ident : $typ:ty ),+ $( $( #[$field_attr:meta] )* $vis:vis $field:ident : $typ:ty ),+
$(,)? $(,)?
} }
)+ ) => { )+ ) => {
// Create the normal struct, without attributes // Create the normal struct, without attributes
$( pub struct $name { $( /*$( #[$field_attr] )**/ $vis $field : $typ, )+ } )+ $( pub struct $name { $( /*$( #[$field_attr] )**/ $vis $field : $typ, )+ } )+
#[cfg(sqlite)] #[cfg(sqlite)]
pub mod __sqlite_model { $( db_object! { @db sqlite | $( #[$attr] )* | $name | $( $( #[$field_attr] )* $field : $typ ),+ } )+ } pub mod __sqlite_model { $( db_object! { @db sqlite | $( #[$attr] )* | $name | $( $( #[$field_attr] )* $field : $typ ),+ } )+ }
#[cfg(mysql)] #[cfg(mysql)]
@@ -177,24 +209,15 @@ macro_rules! db_object {
)+ } )+ }
impl [<$name Db>] { impl [<$name Db>] {
#[allow(clippy::wrong_self_convention)] #[allow(clippy::wrong_self_convention)]
#[inline(always)] pub fn to_db(x: &super::$name) -> Self { Self { $( $field: x.$field.clone(), )+ } } #[inline(always)] pub fn to_db(x: &super::$name) -> Self { Self { $( $field: x.$field.clone(), )+ } }
} }
impl crate::db::FromDb for [<$name Db>] { impl crate::db::FromDb for [<$name Db>] {
type Output = super::$name; type Output = super::$name;
#[allow(clippy::wrong_self_convention)]
#[inline(always)] fn from_db(self) -> Self::Output { super::$name { $( $field: self.$field, )+ } } #[inline(always)] fn from_db(self) -> Self::Output { super::$name { $( $field: self.$field, )+ } }
} }
impl crate::db::FromDb for Vec<[<$name Db>]> {
type Output = Vec<super::$name>;
#[inline(always)] fn from_db(self) -> Self::Output { self.into_iter().map(crate::db::FromDb::from_db).collect() }
}
impl crate::db::FromDb for Option<[<$name Db>]> {
type Output = Option<super::$name>;
#[inline(always)] fn from_db(self) -> Self::Output { self.map(crate::db::FromDb::from_db) }
}
} }
}; };
} }
@@ -202,23 +225,36 @@ macro_rules! db_object {
// Reexport the models, needs to be after the macros are defined so it can access them // Reexport the models, needs to be after the macros are defined so it can access them
pub mod models; pub mod models;
/// Creates a back-up of the database using sqlite3 /// Creates a back-up of the sqlite database
pub fn backup_database() -> Result<(), Error> { /// MySQL/MariaDB and PostgreSQL are not supported.
use std::path::Path; pub fn backup_database(conn: &DbConn) -> Result<(), Error> {
let db_url = CONFIG.database_url(); db_run! {@raw conn:
let db_path = Path::new(&db_url).parent().unwrap(); postgresql, mysql {
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_{}.sqlite3'", db_path, file_date)).execute(conn)?;
Ok(())
}
}
}
let now: DateTime<Utc> = Utc::now(); /// Get the SQL Server version
let file_date = now.format("%Y%m%d").to_string(); pub fn get_sql_server_version(conn: &DbConn) -> String {
let backup_command: String = format!("{}{}{}", ".backup 'db_", file_date, ".sqlite3'"); db_run! {@raw conn:
postgresql, mysql {
Command::new("sqlite3") no_arg_sql_function!(version, diesel::sql_types::Text);
.current_dir(db_path) diesel::select(version).get_result::<String>(conn).unwrap_or_else(|_| "Unknown".to_string())
.args(&["db.sqlite3", &backup_command]) }
.output() sqlite {
.expect("Can't open database, sqlite3 is not available, make sure it's installed and available on the PATH"); no_arg_sql_function!(sqlite_version, diesel::sql_types::Text);
diesel::select(sqlite_version).get_result::<String>(conn).unwrap_or_else(|_| "Unknown".to_string())
Ok(()) }
}
} }
/// Attempts to retrieve a single connection from the managed database pool. If /// Attempts to retrieve a single connection from the managed database pool. If
@@ -259,10 +295,9 @@ mod sqlite_migrations {
use diesel::{Connection, RunQueryDsl}; use diesel::{Connection, RunQueryDsl};
// Make sure the database is up to date (create if it doesn't exist, or run the migrations) // Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = let connection = diesel::sqlite::SqliteConnection::establish(&crate::CONFIG.database_url())?;
diesel::sqlite::SqliteConnection::establish(&crate::CONFIG.database_url())?;
// Disable Foreign Key Checks during migration // Disable Foreign Key Checks during migration
// Scoped to a connection. // Scoped to a connection.
diesel::sql_query("PRAGMA foreign_keys = OFF") diesel::sql_query("PRAGMA foreign_keys = OFF")
.execute(&connection) .execute(&connection)
@@ -270,9 +305,7 @@ mod sqlite_migrations {
// Turn on WAL in SQLite // Turn on WAL in SQLite
if crate::CONFIG.enable_db_wal() { if crate::CONFIG.enable_db_wal() {
diesel::sql_query("PRAGMA journal_mode=wal") diesel::sql_query("PRAGMA journal_mode=wal").execute(&connection).expect("Failed to turn on WAL");
.execute(&connection)
.expect("Failed to turn on WAL");
} }
embedded_migrations::run_with_output(&connection, &mut std::io::stdout())?; embedded_migrations::run_with_output(&connection, &mut std::io::stdout())?;
@@ -288,8 +321,7 @@ mod mysql_migrations {
pub fn run_migrations() -> Result<(), super::Error> { pub fn run_migrations() -> Result<(), super::Error> {
use diesel::{Connection, RunQueryDsl}; use diesel::{Connection, RunQueryDsl};
// Make sure the database is up to date (create if it doesn't exist, or run the migrations) // Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = let connection = diesel::mysql::MysqlConnection::establish(&crate::CONFIG.database_url())?;
diesel::mysql::MysqlConnection::establish(&crate::CONFIG.database_url())?;
// Disable Foreign Key Checks during migration // Disable Foreign Key Checks during migration
// Scoped to a connection/session. // Scoped to a connection/session.
@@ -310,10 +342,9 @@ mod postgresql_migrations {
pub fn run_migrations() -> Result<(), super::Error> { pub fn run_migrations() -> Result<(), super::Error> {
use diesel::{Connection, RunQueryDsl}; use diesel::{Connection, RunQueryDsl};
// Make sure the database is up to date (create if it doesn't exist, or run the migrations) // Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = let connection = diesel::pg::PgConnection::establish(&crate::CONFIG.database_url())?;
diesel::pg::PgConnection::establish(&crate::CONFIG.database_url())?;
// Disable Foreign Key Checks during migration // Disable Foreign Key Checks during migration
// FIXME: Per https://www.postgresql.org/docs/12/sql-set-constraints.html, // FIXME: Per https://www.postgresql.org/docs/12/sql-set-constraints.html,
// "SET CONSTRAINTS sets the behavior of constraint checking within the // "SET CONSTRAINTS sets the behavior of constraint checking within the
// current transaction", so this setting probably won't take effect for // current transaction", so this setting probably won't take effect for

View File

@@ -1,10 +1,12 @@
use std::io::ErrorKind;
use serde_json::Value; use serde_json::Value;
use super::Cipher; use super::Cipher;
use crate::CONFIG; use crate::CONFIG;
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "attachments"] #[table_name = "attachments"]
#[changeset_options(treat_none_as_null="true")] #[changeset_options(treat_none_as_null="true")]
#[belongs_to(super::Cipher, foreign_key = "cipher_uuid")] #[belongs_to(super::Cipher, foreign_key = "cipher_uuid")]
@@ -12,7 +14,7 @@ db_object! {
pub struct Attachment { pub struct Attachment {
pub id: String, pub id: String,
pub cipher_uuid: String, pub cipher_uuid: String,
pub file_name: String, pub file_name: String, // encrypted
pub file_size: i32, pub file_size: i32,
pub akey: Option<String>, pub akey: Option<String>,
} }
@@ -20,13 +22,13 @@ db_object! {
/// Local methods /// Local methods
impl Attachment { impl Attachment {
pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32) -> Self { pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32, akey: Option<String>) -> Self {
Self { Self {
id, id,
cipher_uuid, cipher_uuid,
file_name, file_name,
file_size, file_size,
akey: None, akey,
} }
} }
@@ -34,18 +36,17 @@ impl Attachment {
format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id) format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id)
} }
pub fn get_url(&self, host: &str) -> String {
format!("{}/attachments/{}/{}", host, self.cipher_uuid, self.id)
}
pub fn to_json(&self, host: &str) -> Value { pub fn to_json(&self, host: &str) -> Value {
use crate::util::get_display_size;
let web_path = format!("{}/attachments/{}/{}", host, self.cipher_uuid, self.id);
let display_size = get_display_size(self.file_size);
json!({ json!({
"Id": self.id, "Id": self.id,
"Url": web_path, "Url": self.get_url(host),
"FileName": self.file_name, "FileName": self.file_name,
"Size": self.file_size.to_string(), "Size": self.file_size.to_string(),
"SizeName": display_size, "SizeName": crate::util::get_display_size(self.file_size),
"Key": self.akey, "Key": self.akey,
"Object": "attachment" "Object": "attachment"
}) })
@@ -59,7 +60,6 @@ use crate::error::MapResult;
/// Database methods /// Database methods
impl Attachment { impl Attachment {
pub fn save(&self, conn: &DbConn) -> EmptyResult { pub fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
@@ -92,7 +92,7 @@ impl Attachment {
} }
} }
pub fn delete(self, conn: &DbConn) -> EmptyResult { pub fn delete(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: { db_run! { conn: {
crate::util::retry( crate::util::retry(
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn), || diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(conn),
@@ -100,14 +100,25 @@ impl Attachment {
) )
.map_res("Error deleting attachment")?; .map_res("Error deleting attachment")?;
crate::util::delete_file(&self.get_file_path())?; let file_path = &self.get_file_path();
Ok(())
match crate::util::delete_file(file_path) {
// Ignore "file not found" errors. This can happen when the
// upstream caller has already cleaned up the file as part of
// its own error handling.
Err(e) if e.kind() == ErrorKind::NotFound => {
debug!("File '{}' already deleted.", file_path);
Ok(())
}
Err(e) => Err(e.into()),
_ => Ok(()),
}
}} }}
} }
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> EmptyResult {
for attachment in Attachment::find_by_cipher(&cipher_uuid, &conn) { for attachment in Attachment::find_by_cipher(cipher_uuid, conn) {
attachment.delete(&conn)?; attachment.delete(conn)?;
} }
Ok(()) Ok(())
} }

View File

@@ -1,20 +1,15 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{Duration, NaiveDateTime, Utc};
use serde_json::Value; use serde_json::Value;
use crate::CONFIG;
use super::{ use super::{
Attachment, Attachment, CollectionCipher, Favorite, FolderCipher, Organization, User, UserOrgStatus, UserOrgType,
CollectionCipher,
Favorite,
FolderCipher,
Organization,
User,
UserOrgStatus,
UserOrgType,
UserOrganization, UserOrganization,
}; };
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "ciphers"] #[table_name = "ciphers"]
#[changeset_options(treat_none_as_null="true")] #[changeset_options(treat_none_as_null="true")]
#[belongs_to(User, foreign_key = "user_uuid")] #[belongs_to(User, foreign_key = "user_uuid")]
@@ -43,9 +38,16 @@ db_object! {
pub password_history: Option<String>, pub password_history: Option<String>,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
pub reprompt: Option<i32>,
} }
} }
#[allow(dead_code)]
pub enum RepromptType {
None = 0,
Password = 1, // not currently used in server
}
/// Local methods /// Local methods
impl Cipher { impl Cipher {
pub fn new(atype: i32, name: String) -> Self { pub fn new(atype: i32, name: String) -> Self {
@@ -68,6 +70,7 @@ impl Cipher {
data: String::new(), data: String::new(),
password_history: None, password_history: None,
deleted_at: None, deleted_at: None,
reprompt: None,
} }
} }
} }
@@ -83,46 +86,54 @@ impl Cipher {
use crate::util::format_date; use crate::util::format_date;
let attachments = Attachment::find_by_cipher(&self.uuid, conn); let attachments = Attachment::find_by_cipher(&self.uuid, conn);
let attachments_json: Vec<Value> = attachments.iter().map(|c| c.to_json(host)).collect(); // When there are no attachments use null instead of an empty array
let attachments_json = if attachments.is_empty() {
Value::Null
} else {
attachments.iter().map(|c| c.to_json(host)).collect()
};
let fields_json = self.fields.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); let fields_json = self.fields.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
let password_history_json = self.password_history.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null); let password_history_json =
self.password_history.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
let (read_only, hide_passwords) = let (read_only, hide_passwords) = match self.get_access_restrictions(user_uuid, conn) {
match self.get_access_restrictions(&user_uuid, conn) { Some((ro, hp)) => (ro, hp),
Some((ro, hp)) => (ro, hp), None => {
None => { error!("Cipher ownership assertion failure");
error!("Cipher ownership assertion failure"); (true, true)
(true, true) }
}, };
};
// Get the data or a default empty value to avoid issues with the mobile apps // Get the type_data or a default to an empty json object '{}'.
let mut data_json: Value = serde_json::from_str(&self.data).unwrap_or_else(|_| json!({ // If not passing an empty object, mobile clients will crash.
"Fields":null, let mut type_data_json: Value = serde_json::from_str(&self.data).unwrap_or_else(|_| json!({}));
"Name": self.name,
"Notes":null,
"Password":null,
"PasswordHistory":null,
"PasswordRevisionDate":null,
"Response":null,
"Totp":null,
"Uris":null,
"Username":null
}));
// TODO: ******* Backwards compat start ********** // NOTE: This was marked as *Backwards Compatibilty Code*, but as of January 2021 this is still being used by upstream
// To remove backwards compatibility, just remove this entire section // Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
// and remove the compat code from ciphers::update_cipher_from_data if self.atype == 1 {
if self.atype == 1 && data_json["Uris"].is_array() { if type_data_json["Uris"].is_array() {
let uri = data_json["Uris"][0]["Uri"].clone(); let uri = type_data_json["Uris"][0]["Uri"].clone();
data_json["Uri"] = uri; type_data_json["Uri"] = uri;
} else {
// Upstream always has an Uri key/value
type_data_json["Uri"] = Value::Null;
}
} }
// TODO: ******* Backwards compat end **********
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibilty 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"] = json!(fields_json);
data_json["Name"] = json!(self.name);
data_json["Notes"] = json!(self.notes);
data_json["PasswordHistory"] = json!(password_history_json);
// There are three types of cipher response models in upstream // There are three types of cipher response models in upstream
// Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order // Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order
// of increasing level of detail). bitwarden_rs currently only // of increasing level of detail). vaultwarden currently only
// supports the "cipherDetails" type, though it seems like the // supports the "cipherDetails" type, though it seems like the
// Bitwarden clients will ignore extra fields. // Bitwarden clients will ignore extra fields.
// //
@@ -133,10 +144,13 @@ impl Cipher {
"Type": self.atype, "Type": self.atype,
"RevisionDate": format_date(&self.updated_at), "RevisionDate": format_date(&self.updated_at),
"DeletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))), "DeletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
"FolderId": self.get_folder_uuid(&user_uuid, conn), "FolderId": self.get_folder_uuid(user_uuid, conn),
"Favorite": self.is_favorite(&user_uuid, conn), "Favorite": self.is_favorite(user_uuid, conn),
"Reprompt": self.reprompt.unwrap_or(RepromptType::None as i32),
"OrganizationId": self.organization_uuid, "OrganizationId": self.organization_uuid,
"Attachments": attachments_json, "Attachments": attachments_json,
// We have UseTotp set to true by default within the Organization model.
// This variable together with UsersGetPremium is used to show or hide the TOTP counter.
"OrganizationUseTotp": true, "OrganizationUseTotp": true,
// This field is specific to the cipherDetails type. // This field is specific to the cipherDetails type.
@@ -155,6 +169,12 @@ impl Cipher {
"ViewPassword": !hide_passwords, "ViewPassword": !hide_passwords,
"PasswordHistory": password_history_json, "PasswordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated
"Login": null,
"SecureNote": null,
"Card": null,
"Identity": null,
}); });
let key = match self.atype { let key = match self.atype {
@@ -165,7 +185,7 @@ impl Cipher {
_ => panic!("Wrong type"), _ => panic!("Wrong type"),
}; };
json_object[key] = data_json; json_object[key] = type_data_json;
json_object json_object
} }
@@ -173,18 +193,16 @@ impl Cipher {
let mut user_uuids = Vec::new(); let mut user_uuids = Vec::new();
match self.user_uuid { match self.user_uuid {
Some(ref user_uuid) => { Some(ref user_uuid) => {
User::update_uuid_revision(&user_uuid, conn); User::update_uuid_revision(user_uuid, conn);
user_uuids.push(user_uuid.clone()) user_uuids.push(user_uuid.clone())
} }
None => { None => {
// Belongs to Organization, need to update affected users // Belongs to Organization, need to update affected users
if let Some(ref org_uuid) = self.organization_uuid { if let Some(ref org_uuid) = self.organization_uuid {
UserOrganization::find_by_cipher_and_org(&self.uuid, &org_uuid, conn) UserOrganization::find_by_cipher_and_org(&self.uuid, org_uuid, conn).iter().for_each(|user_org| {
.iter() User::update_uuid_revision(&user_org.user_uuid, conn);
.for_each(|user_org| { user_uuids.push(user_org.user_uuid.clone())
User::update_uuid_revision(&user_org.user_uuid, conn); });
user_uuids.push(user_org.user_uuid.clone())
});
} }
} }
}; };
@@ -242,23 +260,34 @@ impl Cipher {
} }
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
for cipher in Self::find_by_org(org_uuid, &conn) { for cipher in Self::find_by_org(org_uuid, conn) {
cipher.delete(&conn)?; cipher.delete(conn)?;
} }
Ok(()) Ok(())
} }
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for cipher in Self::find_owned_by_user(user_uuid, &conn) { for cipher in Self::find_owned_by_user(user_uuid, conn) {
cipher.delete(&conn)?; cipher.delete(conn)?;
} }
Ok(()) Ok(())
} }
/// Purge all ciphers that are old enough to be auto-deleted.
pub fn purge_trash(conn: &DbConn) {
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
let now = Utc::now().naive_utc();
let dt = now - Duration::days(auto_delete_days);
for cipher in Self::find_deleted_before(&dt, conn) {
cipher.delete(conn).ok();
}
}
}
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn); User::update_uuid_revision(user_uuid, conn);
match (self.get_folder_uuid(&user_uuid, conn), folder_uuid) { match (self.get_folder_uuid(user_uuid, conn), folder_uuid) {
// No changes // No changes
(None, None) => Ok(()), (None, None) => Ok(()),
(Some(ref old), Some(ref new)) if old == new => Ok(()), (Some(ref old), Some(ref new)) if old == new => Ok(()),
@@ -290,7 +319,7 @@ impl Cipher {
/// Returns whether this cipher is owned by an org in which the user has full access. /// Returns whether this cipher is owned by an org in which the user has full access.
pub fn is_in_full_access_org(&self, user_uuid: &str, conn: &DbConn) -> bool { pub fn is_in_full_access_org(&self, user_uuid: &str, conn: &DbConn) -> bool {
if let Some(ref org_uuid) = self.organization_uuid { if let Some(ref org_uuid) = self.organization_uuid {
if let Some(user_org) = UserOrganization::find_by_user_and_org(&user_uuid, &org_uuid, conn) { if let Some(user_org) = UserOrganization::find_by_user_and_org(user_uuid, org_uuid, conn) {
return user_org.has_full_access(); return user_org.has_full_access();
} }
} }
@@ -307,7 +336,7 @@ impl Cipher {
// Check whether this cipher is directly owned by the user, or is in // 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 // a collection that the user has full access to. If so, there are no
// access restrictions. // access restrictions.
if self.is_owned_by_user(&user_uuid) || self.is_in_full_access_org(&user_uuid, &conn) { if self.is_owned_by_user(user_uuid) || self.is_in_full_access_org(user_uuid, conn) {
return Some((false, false)); return Some((false, false));
} }
@@ -348,14 +377,14 @@ impl Cipher {
} }
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool { pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match self.get_access_restrictions(&user_uuid, &conn) { match self.get_access_restrictions(user_uuid, conn) {
Some((read_only, _hide_passwords)) => !read_only, Some((read_only, _hide_passwords)) => !read_only,
None => false, None => false,
} }
} }
pub fn is_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool { pub fn is_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
self.get_access_restrictions(&user_uuid, &conn).is_some() self.get_access_restrictions(user_uuid, conn).is_some()
} }
// Returns whether this cipher is a favorite of the specified user. // Returns whether this cipher is a favorite of the specified user.
@@ -448,7 +477,10 @@ impl Cipher {
pub fn find_owned_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> { pub fn find_owned_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! {conn: { db_run! {conn: {
ciphers::table ciphers::table
.filter(ciphers::user_uuid.eq(user_uuid)) .filter(
ciphers::user_uuid.eq(user_uuid)
.and(ciphers::organization_uuid.is_null())
)
.load::<CipherDb>(conn).expect("Error loading ciphers").from_db() .load::<CipherDb>(conn).expect("Error loading ciphers").from_db()
}} }}
} }
@@ -492,6 +524,15 @@ impl Cipher {
}} }}
} }
/// Find all ciphers that were deleted before the specified datetime.
pub fn find_deleted_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
db_run! {conn: {
ciphers::table
.filter(ciphers::deleted_at.lt(dt))
.load::<CipherDb>(conn).expect("Error loading ciphers").from_db()
}}
}
pub fn get_collections(&self, user_id: &str, conn: &DbConn) -> Vec<String> { pub fn get_collections(&self, user_id: &str, conn: &DbConn) -> Vec<String> {
db_run! {conn: { db_run! {conn: {
ciphers_collections::table ciphers_collections::table

View File

@@ -1,9 +1,9 @@
use serde_json::Value; use serde_json::Value;
use super::{Organization, UserOrgStatus, UserOrgType, UserOrganization, User, Cipher}; use super::{Cipher, Organization, User, UserOrgStatus, UserOrgType, UserOrganization};
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "collections"] #[table_name = "collections"]
#[belongs_to(Organization, foreign_key = "org_uuid")] #[belongs_to(Organization, foreign_key = "org_uuid")]
#[primary_key(uuid)] #[primary_key(uuid)]
@@ -13,7 +13,7 @@ db_object! {
pub name: String, pub name: String,
} }
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)] #[derive(Identifiable, Queryable, Insertable, Associations)]
#[table_name = "users_collections"] #[table_name = "users_collections"]
#[belongs_to(User, foreign_key = "user_uuid")] #[belongs_to(User, foreign_key = "user_uuid")]
#[belongs_to(Collection, foreign_key = "collection_uuid")] #[belongs_to(Collection, foreign_key = "collection_uuid")]
@@ -25,7 +25,7 @@ db_object! {
pub hide_passwords: bool, pub hide_passwords: bool,
} }
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)] #[derive(Identifiable, Queryable, Insertable, Associations)]
#[table_name = "ciphers_collections"] #[table_name = "ciphers_collections"]
#[belongs_to(Cipher, foreign_key = "cipher_uuid")] #[belongs_to(Cipher, foreign_key = "cipher_uuid")]
#[belongs_to(Collection, foreign_key = "collection_uuid")] #[belongs_to(Collection, foreign_key = "collection_uuid")]
@@ -49,12 +49,21 @@ impl Collection {
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"ExternalId": null, // Not support by us
"Id": self.uuid, "Id": self.uuid,
"OrganizationId": self.org_uuid, "OrganizationId": self.org_uuid,
"Name": self.name, "Name": self.name,
"Object": "collection", "Object": "collection",
}) })
} }
pub fn to_json_details(&self, user_uuid: &str, conn: &DbConn) -> Value {
let mut json_object = self.to_json();
json_object["Object"] = json!("collectionDetails");
json_object["ReadOnly"] = json!(!self.is_writable_by_user(user_uuid, conn));
json_object["HidePasswords"] = json!(self.hide_passwords_for_user(user_uuid, conn));
json_object
}
} }
use crate::db::DbConn; use crate::db::DbConn;
@@ -100,8 +109,8 @@ impl Collection {
pub fn delete(self, conn: &DbConn) -> EmptyResult { pub fn delete(self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn); self.update_users_revision(conn);
CollectionCipher::delete_all_by_collection(&self.uuid, &conn)?; CollectionCipher::delete_all_by_collection(&self.uuid, conn)?;
CollectionUser::delete_all_by_collection(&self.uuid, &conn)?; CollectionUser::delete_all_by_collection(&self.uuid, conn)?;
db_run! { conn: { db_run! { conn: {
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid))) diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
@@ -111,18 +120,16 @@ impl Collection {
} }
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
for collection in Self::find_by_organization(org_uuid, &conn) { for collection in Self::find_by_organization(org_uuid, conn) {
collection.delete(&conn)?; collection.delete(conn)?;
} }
Ok(()) Ok(())
} }
pub fn update_users_revision(&self, conn: &DbConn) { pub fn update_users_revision(&self, conn: &DbConn) {
UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn) UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).iter().for_each(|user_org| {
.iter() User::update_uuid_revision(&user_org.user_uuid, conn);
.for_each(|user_org| { });
User::update_uuid_revision(&user_org.user_uuid, conn);
});
} }
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> { pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
@@ -161,10 +168,7 @@ impl Collection {
} }
pub fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &DbConn) -> Vec<Self> { pub fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &DbConn) -> Vec<Self> {
Self::find_by_user_uuid(user_uuid, conn) Self::find_by_user_uuid(user_uuid, conn).into_iter().filter(|c| c.org_uuid == org_uuid).collect()
.into_iter()
.filter(|c| c.org_uuid == org_uuid)
.collect()
} }
pub fn find_by_organization(org_uuid: &str, conn: &DbConn) -> Vec<Self> { pub fn find_by_organization(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
@@ -216,7 +220,7 @@ impl Collection {
} }
pub fn is_writable_by_user(&self, user_uuid: &str, conn: &DbConn) -> bool { pub fn is_writable_by_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match UserOrganization::find_by_user_and_org(&user_uuid, &self.org_uuid, &conn) { match UserOrganization::find_by_user_and_org(user_uuid, &self.org_uuid, conn) {
None => false, // Not in Org None => false, // Not in Org
Some(user_org) => { Some(user_org) => {
if user_org.has_full_access() { if user_org.has_full_access() {
@@ -236,6 +240,28 @@ impl Collection {
} }
} }
} }
pub fn hide_passwords_for_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match UserOrganization::find_by_user_and_org(user_uuid, &self.org_uuid, conn) {
None => true, // Not in Org
Some(user_org) => {
if user_org.has_full_access() {
return false;
}
db_run! { conn: {
users_collections::table
.filter(users_collections::collection_uuid.eq(&self.uuid))
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_collections::hide_passwords.eq(true))
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0) != 0
}}
}
}
}
} }
/// Database methods /// Database methods
@@ -253,8 +279,14 @@ impl CollectionUser {
}} }}
} }
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, hide_passwords: bool, conn: &DbConn) -> EmptyResult { pub fn save(
User::update_uuid_revision(&user_uuid, conn); user_uuid: &str,
collection_uuid: &str,
read_only: bool,
hide_passwords: bool,
conn: &DbConn,
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn);
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
@@ -343,11 +375,9 @@ impl CollectionUser {
} }
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult {
CollectionUser::find_by_collection(&collection_uuid, conn) CollectionUser::find_by_collection(collection_uuid, conn).iter().for_each(|collection| {
.iter() User::update_uuid_revision(&collection.user_uuid, conn);
.for_each(|collection| { });
User::update_uuid_revision(&collection.user_uuid, conn);
});
db_run! { conn: { db_run! { conn: {
diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid))) diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid)))
@@ -356,13 +386,19 @@ impl CollectionUser {
}} }}
} }
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&user_uuid, conn); let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn);
db_run! { conn: { db_run! { conn: {
diesel::delete(users_collections::table.filter(users_collections::user_uuid.eq(user_uuid))) for user in collectionusers {
.execute(conn) diesel::delete(users_collections::table.filter(
.map_res("Error removing user from collections") users_collections::user_uuid.eq(user_uuid)
.and(users_collections::collection_uuid.eq(user.collection_uuid))
))
.execute(conn)
.map_res("Error removing user from collections")?;
}
Ok(())
}} }}
} }
} }
@@ -370,7 +406,7 @@ impl CollectionUser {
/// Database methods /// Database methods
impl CollectionCipher { impl CollectionCipher {
pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn); Self::update_users_revision(collection_uuid, conn);
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
@@ -400,7 +436,7 @@ impl CollectionCipher {
} }
pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn); Self::update_users_revision(collection_uuid, conn);
db_run! { conn: { db_run! { conn: {
diesel::delete( diesel::delete(

View File

@@ -4,7 +4,7 @@ use super::User;
use crate::CONFIG; use crate::CONFIG;
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "devices"] #[table_name = "devices"]
#[changeset_options(treat_none_as_null="true")] #[changeset_options(treat_none_as_null="true")]
#[belongs_to(User, foreign_key = "user_uuid")] #[belongs_to(User, foreign_key = "user_uuid")]
@@ -80,8 +80,8 @@ impl Device {
let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
// Create the JWT claims struct, to send to the client // Create the JWT claims struct, to send to the client
use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
let claims = LoginJWTClaims { let claims = LoginJwtClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + *DEFAULT_VALIDITY).timestamp(), exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
iss: JWT_LOGIN_ISSUER.to_string(), iss: JWT_LOGIN_ISSUER.to_string(),
@@ -117,7 +117,7 @@ impl Device {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult { pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc(); self.updated_at = Utc::now().naive_utc();
db_run! { conn: db_run! { conn:
sqlite, mysql { sqlite, mysql {
crate::util::retry( crate::util::retry(
|| diesel::replace_into(devices::table).values(DeviceDb::to_db(self)).execute(conn), || diesel::replace_into(devices::table).values(DeviceDb::to_db(self)).execute(conn),
@@ -143,8 +143,8 @@ impl Device {
} }
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for device in Self::find_by_user(user_uuid, &conn) { for device in Self::find_by_user(user_uuid, conn) {
device.delete(&conn)?; device.delete(conn)?;
} }
Ok(()) Ok(())
} }
@@ -178,4 +178,15 @@ impl Device {
.from_db() .from_db()
}} }}
} }
pub fn find_latest_active_by_user(user_uuid: &str, conn: &DbConn) -> Option<Self> {
db_run! { conn: {
devices::table
.filter(devices::user_uuid.eq(user_uuid))
.order(devices::updated_at.desc())
.first::<DeviceDb>(conn)
.ok()
.from_db()
}}
}
} }

View File

@@ -1,7 +1,7 @@
use super::{Cipher, User}; use super::{Cipher, User};
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)] #[derive(Identifiable, Queryable, Insertable, Associations)]
#[table_name = "favorites"] #[table_name = "favorites"]
#[belongs_to(User, foreign_key = "user_uuid")] #[belongs_to(User, foreign_key = "user_uuid")]
#[belongs_to(Cipher, foreign_key = "cipher_uuid")] #[belongs_to(Cipher, foreign_key = "cipher_uuid")]
@@ -20,7 +20,7 @@ use crate::error::MapResult;
impl Favorite { impl Favorite {
// Returns whether the specified cipher is a favorite of the specified user. // Returns whether the specified cipher is a favorite of the specified user.
pub fn is_favorite(cipher_uuid: &str, user_uuid: &str, conn: &DbConn) -> bool { pub fn is_favorite(cipher_uuid: &str, user_uuid: &str, conn: &DbConn) -> bool {
db_run!{ conn: { db_run! { conn: {
let query = favorites::table let query = favorites::table
.filter(favorites::cipher_uuid.eq(cipher_uuid)) .filter(favorites::cipher_uuid.eq(cipher_uuid))
.filter(favorites::user_uuid.eq(user_uuid)) .filter(favorites::user_uuid.eq(user_uuid))
@@ -32,23 +32,23 @@ impl Favorite {
// Sets whether the specified cipher is a favorite of the specified user. // Sets whether the specified cipher is a favorite of the specified user.
pub fn set_favorite(favorite: bool, cipher_uuid: &str, user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn set_favorite(favorite: bool, cipher_uuid: &str, user_uuid: &str, conn: &DbConn) -> EmptyResult {
let (old, new) = (Self::is_favorite(cipher_uuid, user_uuid, &conn), favorite); let (old, new) = (Self::is_favorite(cipher_uuid, user_uuid, conn), favorite);
match (old, new) { match (old, new) {
(false, true) => { (false, true) => {
User::update_uuid_revision(user_uuid, &conn); User::update_uuid_revision(user_uuid, conn);
db_run!{ conn: { db_run! { conn: {
diesel::insert_into(favorites::table) diesel::insert_into(favorites::table)
.values(( .values((
favorites::user_uuid.eq(user_uuid), favorites::user_uuid.eq(user_uuid),
favorites::cipher_uuid.eq(cipher_uuid), favorites::cipher_uuid.eq(cipher_uuid),
)) ))
.execute(conn) .execute(conn)
.map_res("Error adding favorite") .map_res("Error adding favorite")
}} }}
} }
(true, false) => { (true, false) => {
User::update_uuid_revision(user_uuid, &conn); User::update_uuid_revision(user_uuid, conn);
db_run!{ conn: { db_run! { conn: {
diesel::delete( diesel::delete(
favorites::table favorites::table
.filter(favorites::user_uuid.eq(user_uuid)) .filter(favorites::user_uuid.eq(user_uuid))
@@ -59,7 +59,7 @@ impl Favorite {
}} }}
} }
// Otherwise, the favorite status is already what it should be. // Otherwise, the favorite status is already what it should be.
_ => Ok(()) _ => Ok(()),
} }
} }

View File

@@ -4,7 +4,7 @@ use serde_json::Value;
use super::{Cipher, User}; use super::{Cipher, User};
db_object! { db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "folders"] #[table_name = "folders"]
#[belongs_to(User, foreign_key = "user_uuid")] #[belongs_to(User, foreign_key = "user_uuid")]
#[primary_key(uuid)] #[primary_key(uuid)]
@@ -16,7 +16,7 @@ db_object! {
pub name: String, pub name: String,
} }
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)] #[derive(Identifiable, Queryable, Insertable, Associations)]
#[table_name = "folders_ciphers"] #[table_name = "folders_ciphers"]
#[belongs_to(Cipher, foreign_key = "cipher_uuid")] #[belongs_to(Cipher, foreign_key = "cipher_uuid")]
#[belongs_to(Folder, foreign_key = "folder_uuid")] #[belongs_to(Folder, foreign_key = "folder_uuid")]
@@ -107,8 +107,7 @@ impl Folder {
pub fn delete(&self, conn: &DbConn) -> EmptyResult { pub fn delete(&self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn); User::update_uuid_revision(&self.user_uuid, conn);
FolderCipher::delete_all_by_folder(&self.uuid, &conn)?; FolderCipher::delete_all_by_folder(&self.uuid, conn)?;
db_run! { conn: { db_run! { conn: {
diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid))) diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid)))
@@ -118,8 +117,8 @@ impl Folder {
} }
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for folder in Self::find_by_user(user_uuid, &conn) { for folder in Self::find_by_user(user_uuid, conn) {
folder.delete(&conn)?; folder.delete(conn)?;
} }
Ok(()) Ok(())
} }

View File

@@ -6,6 +6,7 @@ mod favorite;
mod folder; mod folder;
mod org_policy; mod org_policy;
mod organization; mod organization;
mod send;
mod two_factor; mod two_factor;
mod user; mod user;
@@ -17,5 +18,6 @@ pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher}; pub use self::folder::{Folder, FolderCipher};
pub use self::org_policy::{OrgPolicy, OrgPolicyType}; pub use self::org_policy::{OrgPolicy, OrgPolicyType};
pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
pub use self::send::{Send, SendType};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::user::{Invitation, User}; pub use self::user::{Invitation, User, UserStampException};

Some files were not shown because too many files have changed in this diff Show More