Compare commits

..

147 Commits
1.6.0 ... 1.8.0

Author SHA1 Message Date
Daniel García
349cb33fbd Updated dependencies 2019-03-23 19:48:22 +01:00
Daniel García
d7542b6818 Merge pull request #437 from njfox/fix-smtp-error
Split up long line to stop SMTP from breaking
2019-03-21 14:22:57 +01:00
Nick Fox
7976d39d9d Adjust whitespace 2019-03-20 23:29:29 -04:00
Nick Fox
5ee9676941 Break up long line to stop SMTP from breaking 2019-03-20 23:24:30 -04:00
Daniel García
4b40cda910 Added domain blacklist regex for icons service and improved valid domain check.
Reorganized the icons code a bit.
2019-03-18 22:12:39 +01:00
Daniel García
4689ed7b30 Changed uppercase deserializer to avoid a clone. 2019-03-18 22:02:37 +01:00
Daniel García
084bc2aee3 Use final release of lettre and update dependencies 2019-03-17 14:43:22 +01:00
Daniel García
6d7e15b2fd Use web vault 2.9.0 release 2019-03-14 13:29:03 +01:00
Daniel García
61515160a7 Allow changing error codes and create an empty error.
Return 404 instead of 400 when no accounts breached.
2019-03-14 00:17:36 +01:00
Daniel García
a25bfdd16d Remove unused features from multipart (integration with other servers) 2019-03-13 15:57:00 +01:00
Daniel García
e93538cea9 Add option to use wrapped TLS in email, instead of STARTTLS upgrade 2019-03-10 14:45:42 +01:00
Daniel García
b4244b28b6 Update admin page scripts and fixed broken tooltip 2019-03-09 14:41:34 +01:00
Daniel García
43f9038325 Add option to force resync clients in admin panel 2019-03-07 21:08:33 +01:00
Daniel García
27872f476e Update dependencies 2019-03-07 20:22:08 +01:00
Daniel García
339044f8aa Add warning about config panel values overriding env vars. 2019-03-07 20:22:02 +01:00
Daniel García
0718a090e1 Trim spaces from admin token during authentication and validate that the admin panel token is not empty 2019-03-07 20:21:50 +01:00
Daniel García
9e1f030a80 Explicitly close SMTP connection in case of error. 2019-03-07 20:21:10 +01:00
Daniel García
04922f6aa0 Some formatting and dependency updates 2019-03-03 16:11:55 +01:00
Daniel García
7d2bc9e162 Added option to force 2fa at logins and made some changes to two factor code.
Added newlines to config options to keep them a reasonable length.
2019-03-03 16:09:15 +01:00
Daniel García
c6c00729e3 Update vault to new version. No need to wait for a release when even the official web vault is already using it 2019-02-27 17:28:04 +01:00
Daniel García
10756b0920 Update dependencies and fix some lints 2019-02-27 17:21:04 +01:00
Daniel García
1eb1502a07 Merge pull request #416 from mprasil/armv6
Armv6
2019-02-25 18:26:53 +01:00
Miroslav Prasil
30e72a96a9 Symlink missing ld-linux file 2019-02-25 16:17:34 +00:00
Daniel García
2646db78a4 Merge pull request #414 from FrankPetrilli/patch-1
Minor typo fix conect => connect
2019-02-25 14:21:28 +01:00
Miroslav Prasil
f5358b13f5 Add Dockerfile for armv6 2019-02-25 12:17:22 +00:00
Frank Petrilli
d156170971 Minor typo fix conect => connect 2019-02-24 16:08:38 -08:00
Daniel García
d9bfe847db Merge pull request #410 from gdamjan/remove-uneeded-mutability
remove some unneeded mutability
2019-02-22 22:52:53 +01:00
Дамјан Георгиевски
473f8b8e31 remove some unneeded mutability 2019-02-22 20:25:50 +01:00
Daniel García
aeb4b4c8a5 Remove verbose, otherwise the logs get filled with useless info 2019-02-22 16:16:07 +01:00
Daniel García
980a3e45db Set up CI with Azure Pipelines 2019-02-22 15:51:30 +01:00
Daniel García
5794969f5b Merge pull request #406 from shauder/feature/disable-admin-token
Allow the Admin token to be disabled in the advanced menu
2019-02-20 23:06:52 +01:00
Shane Faulkner
8b5b06c3d1 Allow the Admin token to be disabled in the advanced menu 2019-02-20 14:56:08 -06:00
Daniel García
b50c27b619 Print a warning when an env variable is being overriden by the config file, and reorganize the main file a bit.
Modified the JWT key generation, now it should also show the output of OpenSSL in the logs.
2019-02-20 20:59:37 +01:00
Daniel García
5ee04e31e5 Updated dependencies, removed some unnecessary clones and fixed some lints 2019-02-20 17:54:18 +01:00
Daniel García
bf6ae91a6d Remove margins on small devices 2019-02-18 20:43:34 +01:00
Daniel García
828e3a5795 Add extra padding when the toolbar collapses in small devices 2019-02-18 20:33:32 +01:00
Daniel García
7b5bcd45f8 Show read-only options in the config panel and the env variable names in the tooltips 2019-02-18 19:25:33 +01:00
Daniel García
72de16fb86 Merge pull request #404 from mprasil/disable_wal
Add an option to not enable WAL (should help in #399)
2019-02-18 16:10:16 +01:00
Miroslav Prasil
0b903fc5f4 Extended the template file and refer to wiki 2019-02-18 14:57:21 +00:00
Miroslav Prasil
4df686f49e Add an option to not enable WAL (should help in #399) 2019-02-18 10:48:48 +00:00
Daniel García
d7eeaaf249 Escape user data from admin panel when calling JS 2019-02-17 15:24:14 +01:00
Daniel García
a744b9437a Implemented multiple U2f keys, key names, and compromised checks 2019-02-16 23:07:48 +01:00
Daniel García
6027b969f5 Delete old devices when deauthorizing user sessions 2019-02-16 23:06:26 +01:00
Daniel García
93805a5d7b Fix Yubikeys deleted on error 2019-02-16 21:30:55 +01:00
Daniel García
71da961ecd Merge pull request #402 from mprasil/version_in_docker
Include git repo in build so we get version
2019-02-16 12:20:25 +01:00
Miroslav Prasil
dd421809e5 Include git repo in build so we get version 2019-02-16 08:50:16 +00:00
Daniel García
274ea9a4f2 Use the latest fast_chemail crate directly, with the fix 2019-02-15 14:39:30 +01:00
Daniel García
8743d18aca Update travis image and remove now-ignored sudo tag 2019-02-13 18:50:45 +01:00
Daniel García
d3773a433a Removed list of mounted routes at startup by default, with option to add it back. This would get annoying when starting the server frequently, because it printed ~130 lines of mostly useless info 2019-02-13 00:03:16 +01:00
Daniel García
0f0a87becf Add version to initial message 2019-02-12 22:47:00 +01:00
Daniel García
4b57bb8eeb Merge pull request #394 from BlackDex/icon-timeout
Added config option for icon download timeout
2019-02-12 22:00:12 +01:00
BlackDex
3b27dbb0aa Added config option for icon download timeout 2019-02-12 21:56:28 +01:00
Daniel García
ff2fbd322e Update deps and fix email check 2019-02-12 15:01:02 +01:00
Daniel García
9636f33fdb Implement constant time equal check for admin, 2fa recover and 2fa remember tokens 2019-02-11 23:45:55 +01:00
Daniel García
bbe2a1b264 Merge pull request #391 from TheMardy/master
Updated Email Templates
2019-02-10 22:03:20 +01:00
Daniel García
79fdfd6524 Add missing url parameter 2019-02-10 21:40:20 +01:00
Daniel García
d086a99e5b Implemented HTML emails with text alternative 2019-02-10 19:12:34 +01:00
TheMardy
22b0b95209 Added HTML templates (+14 squashed commit)
Squashed commit:

[ece2260] Plaintext send_org_invite

[01d4884] Plaintext pw_hint_some

[6ce5173] Plaintext pw_hint_none

[881af3e] Plaintext invite_confirmed

[ce78621] Plaintext invite_accepted

[13a44a4] Rename send_org_invite.hbs to send_org_invite.html.hbs

[b52bf2f] Rename pw_hint_some.hbs to pw_hint_some.html.hbs

[e0d1aeb] Rename pw_hint_none.hbs to pw_hint_none.html.hbs

[898dbcd] Rename invite_confirmed.hbs to invite_confirmed.html.hbs

[107af31] Rename invite_accepted.hbs to invite_accepted.html.hbs

[d26d662] Updated send_org_invite template

[71f47af] Updated pw_hint_some template

[c2ca3c2] Updated pw_hint_none template

[50f8bfb] Updated invite_accepted template

[17f96f8] Updated invite_confirmed template
2019-02-10 19:04:18 +01:00
Daniel García
28d1588e73 Show version in admin panel 2019-02-10 16:02:46 +01:00
Daniel García
f3b1a5ff3e Error when admin panel is disabled 2019-02-10 15:26:19 +01:00
Daniel García
330e90a6ac Hide secrets in config panel 2019-02-08 20:49:04 +01:00
Daniel García
8fac72db53 Mention relation between DOMAIN and mail settings in .env template 2019-02-08 19:21:48 +01:00
Daniel García
820c8b0dce Change use of deserialize_with for Option iterator 2019-02-08 19:12:08 +01:00
Daniel García
8b4a6f2a64 Fixed some clippy lints and changed update_uuid_revision to only use one db query 2019-02-08 18:45:07 +01:00
Daniel García
ef63342e20 Add reset user config button 2019-02-06 17:34:32 +01:00
Daniel García
89840790e7 Fix .env path traversal issue 2019-02-06 17:34:31 +01:00
Daniel García
a72809b225 Yubico and SMTP enable/disable master switches 2019-02-06 17:34:31 +01:00
Daniel García
9976e4736e Add groups 2019-02-06 17:34:31 +01:00
Daniel García
dc92f07232 Added env variable to select config file. Initial work towards groups and added tooltips with descriptions and nicer names 2019-02-06 17:34:30 +01:00
Daniel García
3db815b969 Implemented config form and fixed config priority 2019-02-06 17:34:30 +01:00
Daniel García
ade293cf52 Save config 2019-02-06 17:34:29 +01:00
Daniel García
877408b808 Implement basic config loading and updating. No save to file yet. 2019-02-06 17:34:29 +01:00
Daniel García
86ed75bf7c Config can now be serialized / deserialized 2019-02-06 17:34:29 +01:00
Daniel García
20d8d800f3 Updated dependencies 2019-02-06 17:34:29 +01:00
Daniel García
7ce06b3808 Merge pull request #387 from mprasil/collections_edit_revision
Update revision when adding or removing cipher from collection
2019-02-06 17:33:03 +01:00
Miroslav Prasil
08ca47cadb Update revision when adding or removing cipher from collection 2019-02-06 14:47:47 +00:00
Daniel García
0bd3a26051 Merge pull request #386 from mprasil/revision_collection_delete
Update revision of affected users when deleting Collection
2019-02-06 14:53:16 +01:00
Miroslav Prasil
5272b465cc Update revision of affected users when deleting Collection 2019-02-06 13:39:32 +00:00
Daniel García
b75f38033b Merge pull request #385 from mprasil/update_revision_retry
Retry updating revision - fixes #383
2019-02-05 15:31:07 +01:00
Miroslav Prasil
637f655b6f Do not allocate uneccessary Vec 2019-02-05 14:16:07 +00:00
Miroslav Prasil
b3f7394c06 Do not update revision at the end, as we already did that 2019-02-05 14:09:59 +00:00
Miroslav Prasil
1a5ecd4d4a cipher does not need to be mutable 2019-02-05 13:52:30 +00:00
Miroslav Prasil
bd65c4e312 Remove superfluous cipher.save() call 2019-02-05 13:49:30 +00:00
Miroslav Prasil
bce656c787 Retry updating revision - fixes #383 2019-02-05 11:52:11 +00:00
Daniel García
06522c9ac0 Merge pull request #382 from BlackDex/iter-iconlist
Loop through the iconlist until an icon is found
2019-02-04 18:54:15 +01:00
BlackDex
9026cc8d42 Fixed issue when the iconlist is smaller then 5
When the iconlist was smaller then 5 items, it would cause a panic.
Solved by using .truncate() on the iconlist.
2019-02-04 17:27:40 +01:00
BlackDex
574b040142 Loop through the iconlist until an icon is found
Loop for a maximum of 5 times through the iconlist or until a
successful download of an icon.
2019-02-04 16:59:52 +01:00
Daniel García
48113b7bd9 Merge pull request #381 from BlackDex/issue-380
Fixed issue #380
2019-02-04 13:56:11 +01:00
BlackDex
c13f115473 Fixed issue #380
- Created a separate function for parsing the sizes attribute
 - Parsing sizes now with regex
 - Should work with any non-digit separator
2019-02-04 12:55:39 +01:00
Daniel García
1e20f9f1d8 Merge pull request #377 from BlackDex/icon-cookies
Added cookies to the icon download request.
2019-01-31 18:16:30 +01:00
BlackDex
bc461d9baa Some small changes on the iter of the cookies 2019-01-31 17:58:03 +01:00
BlackDex
5016e30cf2 Added cookies to the icon download request.
Some sites use XSRF Tokens, or other Tokens to verify a subseqense
response. The cookies which are sent during the page request are now
used when downloading the favicon.

A site which uses this is mijn.ing.nl.
2019-01-31 15:49:58 +01:00
Daniel García
f42ac5f2c0 Update web vault error message 2019-01-29 21:45:25 +01:00
Daniel García
2a60414031 Reuse the client between requests, and use the client when downloading the icons themselves 2019-01-29 21:21:26 +01:00
Daniel García
9a2a304860 Merge pull request #372 from BlackDex/better-href-fix
Changed the way to fix the href
2019-01-29 19:30:53 +01:00
BlackDex
feb74a5e86 Changed the way to fix the href
- Using url from reqwest to fix href, this fixes:
   + "//domain.com/icon.png"
   + "relative/path/to/icon.png"
   + "/absolute/path/to/icon.png"
 - Removed fix_href function
 - Some variable changes
2019-01-29 18:08:23 +01:00
Daniel García
c0e350b734 Disable icon downloads, accept optional query after icon href, format and clippy fixes 2019-01-28 23:58:32 +01:00
Daniel García
bef1183c49 Only send one notification per vault import and purge, improve move ciphers functions 2019-01-28 00:39:14 +01:00
Daniel García
f935f5cf46 Remove local icon extractor 2019-01-27 16:42:30 +01:00
Daniel García
07388d327f Merge pull request #370 from BlackDex/favicons
Added better favicon downloader.
2019-01-27 16:37:47 +01:00
BlackDex
4de16b2d17 Removed unwrap and added ? 2019-01-27 16:25:02 +01:00
BlackDex
da068a43c1 Moved function call to get_icon_url to prevent error bubbeling 2019-01-27 16:03:18 +01:00
BlackDex
9657463717 Added better favicon downloader. 2019-01-27 15:39:19 +01:00
Daniel García
69036cc6a4 Add disabled user badge (no password) and deauthorize button to admin page. 2019-01-26 19:28:54 +01:00
Daniel García
700e084101 Add 2FA icon to admin panel 2019-01-25 18:50:57 +01:00
Daniel García
a1dc47b826 Change config to thread-safe system, needed for a future config panel.
Improved some two factor methods.
2019-01-25 18:24:57 +01:00
Daniel García
86de0ca17b Fix editing users from collections menu 2019-01-25 17:43:51 +01:00
Daniel García
80414f8452 Merge pull request #365 from CoreFiling/master
Fix the list of users with access to a collection to display correctly.
2019-01-25 16:52:27 +01:00
Stephen White
fc0e239bdf No point calling find_by_uuid now we don't use the result. 2019-01-25 14:25:15 +00:00
Stephen White
928ad6c1d8 Fix the list of users with access to a collection to display correctly.
https://github.com/dani-garcia/bitwarden_rs/issues/364
2019-01-25 14:18:06 +00:00
Daniel García
9d027b96d8 Update web-vault to fix U2F NotTrustedAnchor error 2019-01-24 18:43:22 +01:00
Daniel García
ddd49596ba Fix invite empty email 2019-01-22 17:26:17 +01:00
Daniel García
b8cabadd43 Fix admin page links 2019-01-21 23:41:27 +01:00
Daniel García
ce42b07a80 Update Diesel to 1.4 and other dependencies 2019-01-21 15:29:52 +01:00
Daniel García
bfd93e5b13 Show organizations in admin panel, implement reload templates option 2019-01-20 17:43:56 +01:00
Daniel García
a797459560 Implement HIBP check [WIP].
Add extra security attributes to admin cookie.
Error handling.
2019-01-20 15:36:33 +01:00
Daniel García
6cbb683f99 Rename admin templates to match email 2019-01-19 22:59:32 +01:00
Daniel García
92bbb98d48 Created base template 2019-01-19 22:12:52 +01:00
Daniel García
834c847746 Implement admin JWT cookie, separate JWT issuers for each type of token and migrate admin page to handlebars template 2019-01-19 21:41:49 +01:00
Daniel García
97aa407fe4 Move email templates to subfolder 2019-01-19 17:40:18 +01:00
Daniel García
86a254ad9e Ignore build.rs git errors 2019-01-19 17:35:47 +01:00
Daniel García
64c38856cc Merge pull request #348 from mprasil/c_version
Bump the vault version used to the latest one
2019-01-18 13:20:15 +01:00
Miroslav Prasil
b4f6206eda Bump the vault version used to the latest one 2019-01-18 11:52:36 +00:00
Daniel García
82f828a327 Merge pull request #347 from TBK/patch-1
Add Feature-Policy header
2019-01-17 21:33:45 +01:00
TBK
d8116a80df Add Feature-Policy header 2019-01-17 21:08:31 +01:00
Daniel García
e0aec8d373 Use new i64::to_be_bytes and remove byteorder dep
(https://doc.rust-lang.org/stable/std/primitive.i64.html#method.to_be_bytes)
2019-01-16 22:14:17 +01:00
Daniel García
1ce2587330 Correct update cipher order: first save cipher, then cipher-folder, then notify 2019-01-16 19:57:49 +01:00
Daniel García
20964ac2d8 Merge pull request #343 from mprasil/share_fix
Fix sharing the item to organization.
2019-01-16 12:58:58 +01:00
Miroslav Prasil
71a10e0378 Fix sharing the item to organization. 2019-01-16 11:33:43 +00:00
Daniel García
9bf13b7872 Can't return inside multipart closure 2019-01-15 22:00:41 +01:00
Daniel García
d420992f8c Update some function calls to use ? 2019-01-15 21:47:16 +01:00
Daniel García
c259a0e3e2 Save recovery code when using yubikey and stop repeating headers.user everywhere 2019-01-15 21:38:21 +01:00
Daniel García
432be274ba Improve org mismatch check, consider different orgs 2019-01-15 17:31:03 +01:00
Daniel García
484bf5b703 Check that the client is not updating an outdated cipher, that should be part of an org now 2019-01-15 16:35:08 +01:00
Daniel García
979b6305af Update dependencies 2019-01-15 15:30:12 +01:00
Daniel García
4bf32af60e Fix folder notifications, enable template strict mode and add missing option to env template 2019-01-15 15:28:47 +01:00
Daniel García
0e4a746eeb Added SMTP_FROM_NAME 2019-01-15 15:28:47 +01:00
Daniel García
2fe919cc5e Embed the default templates 2019-01-15 15:28:46 +01:00
Daniel García
bcd750695f Default to $data_folder/templates and remove dev option (use TEMPLATES_FOLDER=src/static/templates instead) 2019-01-15 15:28:46 +01:00
Daniel García
19b6bb0fd6 Initial stab at templates 2019-01-15 15:28:46 +01:00
Daniel García
60f6a350be Update yubico to fix OpenSSL error 2019-01-13 14:37:17 +01:00
Daniel García
f571df7367 Revert yubikey feature, not needed anymore 2019-01-12 15:28:41 +01:00
Daniel García
de51bc782e Updated dependencies, removing need for yubico fork 2019-01-12 15:23:46 +01:00
Daniel García
c5aef60bd7 Implement unofficial warning message 2019-01-11 14:20:42 +01:00
Daniel García
8b07ecb937 Include warning to not report bugs upstream 2019-01-11 13:54:43 +01:00
Daniel García
6f52104324 Fix casing error in Attachment, should fix 'Attachment doesn't exist' errors 2019-01-11 01:35:15 +01:00
Daniel García
1d7f704754 Send CipherUpdate when adding and deleting attachments 2019-01-11 01:12:54 +01:00
55 changed files with 4149 additions and 2133 deletions

View File

@@ -9,10 +9,6 @@ data
.idea
*.iml
# Git files
.git
.gitignore
# Documentation
*.md

View File

@@ -10,6 +10,12 @@
# ICON_CACHE_FOLDER=data/icon_cache
# ATTACHMENTS_FOLDER=data/attachments
## Templates data folder, by default uses embedded templates
## Check source code to see the format
# TEMPLATES_FOLDER=/path/to/templates
## Automatically reload the templates for every request, slow, use only for development
# RELOAD_TEMPLATES=false
## Cache time-to-live for successfully obtained icons, in seconds (0 is "forever")
# ICON_CACHE_TTL=2592000
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
@@ -19,6 +25,9 @@
# WEB_VAULT_FOLDER=web-vault/
# WEB_VAULT_ENABLED=true
## Enables websocket notifications
# WEBSOCKET_ENABLED=false
## Controls the WebSocket server address and port
# WEBSOCKET_ADDRESS=0.0.0.0
# WEBSOCKET_PORT=3012
@@ -34,11 +43,34 @@
## It's recommended to also set 'ROCKET_CLI_COLORS=off'
# LOG_FILE=/path/to/log
## Use a local favicon extractor
## Set to false to use bitwarden's official icon servers
## Set to true to use the local version, which is not as smart,
## but it doesn't send the cipher domains to bitwarden's servers
# LOCAL_ICON_EXTRACTOR=false
## Enable WAL for the DB
## 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,
## this setting only prevents bitwarden_rs from automatically enabling it on start.
## 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.
# ENABLE_DB_WAL=true
## Disable icon downloading
## 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,
## otherwise it will delete them and they won't be downloaded again.
# DISABLE_ICON_DOWNLOAD=false
## Icon download timeout
## Configure the timeout value when downloading the favicons.
## The default is 10 seconds, but this could be to low on slower network connections
# ICON_DOWNLOAD_TIMEOUT=10
## Icon blacklist Regex
## Any domains or IPs that match this regex won't be fetched by the icon service.
## Useful to hide other servers in the local network. Check the WIKI for more details
# ICON_BLACKLIST_REGEX=192\.168\.1\.[0-9].*^
## Disable 2FA remember
## Enabling this would force the users to use a second factor to login every time.
## Note that the checkbox would still be present, but ignored.
# DISABLE_2FA_REMEMBER=false
## Controls if new users can register
# SIGNUPS_ALLOWED=true
@@ -47,6 +79,7 @@
## One option is to use 'openssl rand -base64 48'
## If not set, the admin panel is disabled
# ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp
# DISABLE_ADMIN_TOKEN=false
## Invitations org admins to invite users, even when signups are disabled
# INVITATIONS_ALLOWED=true
@@ -60,7 +93,8 @@
## Domain settings
## The domain must match the address from where you access the server
## Unless you are using U2F, or having problems with attachments not downloading, there is no need to change this
## It's recommended to configure this value, otherwise certain functionality might not work,
## like attachment downloads, email links and U2F.
## For U2F to work, the server must use HTTPS, you can use Let's Encrypt for free certs
# DOMAIN=https://bw.domain.tld:8443
@@ -79,9 +113,11 @@
# 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.
## 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
# SMTP_HOST=smtp.domain.tld
# SMTP_FROM=bitwarden-rs@domain.tld
# SMTP_FROM_NAME=Bitwarden_RS
# SMTP_PORT=587
# SMTP_SSL=true
# SMTP_USERNAME=username

View File

@@ -1,9 +1,9 @@
# Copied from Rocket's .travis.yml
dist: xenial
language: rust
sudo: required # so we get a VM with higher specs
dist: trusty # so we get a VM with higher specs
rust: nightly
cache: cargo
rust:
- nightly
script:
- cargo build --verbose --all-features
# Nothing to install
install: true
script: cargo build --all-features

1757
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,7 @@ publish = false
build = "build.rs"
[features]
default = ["enable_yubikey"]
enable_syslog = ["syslog", "fern/syslog-4"]
enable_yubikey = ["yubico"]
[dependencies]
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
@@ -21,24 +19,24 @@ rocket = { version = "0.4.0", features = ["tls"], default-features = false }
rocket_contrib = "0.4.0"
# HTTP client
reqwest = "0.9.6"
reqwest = "0.9.12"
# multipart/form-data support
multipart = "0.15.4"
multipart = { version = "0.16.1", features = ["server"], default-features = false }
# WebSockets library
ws = "0.7.9"
ws = "0.8.0"
# MessagePack library
rmpv = "0.4.0"
# Concurrent hashmap implementation
chashmap = "2.2.0"
chashmap = "2.2.2"
# A generic serialization/deserialization framework
serde = "1.0.84"
serde_derive = "1.0.84"
serde_json = "1.0.34"
serde = "1.0.89"
serde_derive = "1.0.89"
serde_json = "1.0.39"
# Logging
log = "0.4.6"
@@ -46,17 +44,17 @@ fern = "0.5.7"
syslog = { version = "4.0.1", optional = true }
# A safe, extensible ORM and Query builder
diesel = { version = "1.3.3", features = ["sqlite", "chrono", "r2d2"] }
diesel_migrations = { version = "1.3.0", features = ["sqlite"] }
diesel = { version = "1.4.2", features = ["sqlite", "chrono", "r2d2"] }
diesel_migrations = { version = "1.4.0", features = ["sqlite"] }
# Bundled SQLite
libsqlite3-sys = { version = "0.9.3", features = ["bundled"] }
libsqlite3-sys = { version = "0.12.0", features = ["bundled"] }
# Crypto library
ring = { version = "0.13.5", features = ["rsa_signing"] }
# UUID generation
uuid = { version = "0.7.1", features = ["v4"] }
uuid = { version = "0.7.2", features = ["v4"] }
# Date and time library for Rust
chrono = "0.4.6"
@@ -74,36 +72,33 @@ jsonwebtoken = "5.0.1"
u2f = "0.1.4"
# Yubico Library
yubico = { version = "=0.4.0", features = ["online"], default-features = false, optional = true }
yubico = { version = "0.5.1", features = ["online"], default-features = false }
# A `dotenv` implementation for Rust
dotenv = { version = "0.13.0", default-features = false }
# Lazy static macro
lazy_static = { version = "1.2.0", features = ["nightly"] }
lazy_static = "1.3.0"
# More derives
derive_more = "0.13.0"
derive_more = "0.14.0"
# Numerical libraries
num-traits = "0.2.6"
num-derive = "0.2.3"
num-derive = "0.2.4"
# Email libraries
lettre = "0.9.0"
lettre_email = "0.9.0"
native-tls = "0.2.2"
# Number encoding library
byteorder = "1.2.7"
# Template library
handlebars = "1.1.0"
# For favicon extraction from main website
soup = "0.3.0"
regex = "1.1.2"
[patch.crates-io]
# Add support for Timestamp type
rmp = { git = 'https://github.com/dani-garcia/msgpack-rust' }
# Use new native_tls version 0.2
lettre = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' }
lettre_email = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' }
# Allows optional libusb support
yubico = { git = 'https://github.com/dani-garcia/yubico-rs' }

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE #######################
FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b"
ENV VAULT_VERSION "v2.9.0"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE #######################
FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b"
ENV VAULT_VERSION "v2.9.0"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"
@@ -55,8 +55,7 @@ COPY . .
# Build
RUN rustup target add aarch64-unknown-linux-gnu
# TODO: Enable yubico when #262 is fixed
RUN cargo build --release --target=aarch64-unknown-linux-gnu -v --no-default-features
RUN cargo build --release --target=aarch64-unknown-linux-gnu -v
######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE #######################
FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b"
ENV VAULT_VERSION "v2.9.0"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

93
Dockerfile.armv6 Normal file
View File

@@ -0,0 +1,93 @@
# Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE #######################
FROM alpine as vault
ENV VAULT_VERSION "v2.9.0"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"
RUN apk add --update-cache --upgrade \
curl \
tar
RUN mkdir /web-vault
WORKDIR /web-vault
RUN curl -L $URL | tar xz
RUN ls
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
# we need the Rust compiler and Cargo tooling
FROM rust as build
RUN apt-get update \
&& apt-get install -y \
gcc-arm-linux-gnueabi \
&& mkdir -p ~/.cargo \
&& echo '[target.arm-unknown-linux-gnueabi]' >> ~/.cargo/config \
&& echo 'linker = "arm-linux-gnueabi-gcc"' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
WORKDIR /app
# Prepare openssl armel libs
RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
/etc/apt/sources.list.d/deb-src.list \
&& dpkg --add-architecture armel \
&& apt-get update \
&& apt-get install -y \
libssl-dev:armel \
libc6-dev:armel
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"
# Copies the complete project
# To avoid copying unneeded files, use .dockerignore
COPY . .
# Build
RUN rustup target add arm-unknown-linux-gnueabi
RUN cargo build --release --target=arm-unknown-linux-gnueabi -v
######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image
# because we already have a binary built
FROM balenalib/rpi-debian:stretch
ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10
RUN [ "cross-build-start" ]
# Install needed libraries
RUN apt-get update && apt-get install -y\
openssl\
ca-certificates\
--no-install-recommends\
&& ln -s /lib/ld-linux-armhf.so.3 /lib/ld-linux.so.3\
&& rm -rf /var/lib/apt/lists/*
RUN mkdir /data
RUN [ "cross-build-end" ]
VOLUME /data
EXPOSE 80
# Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage
COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
# Configures the startup!
CMD ./bitwarden_rs

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE #######################
FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b"
ENV VAULT_VERSION "v2.9.0"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -11,7 +11,9 @@
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/bitwarden_rs).
_*Note, that 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.**
#### ⚠️**IMPORTANT**⚠️: When using this server, please report any Bitwarden related bug-reports or suggestions [here](https://github.com/dani-garcia/bitwarden_rs/issues/new), regardless of whatever clients you are using (mobile, desktop, browser...). DO NOT use the official support channels.
---

17
azure-pipelines.yml Normal file
View File

@@ -0,0 +1,17 @@
pool:
vmImage: 'Ubuntu-16.04'
steps:
- script: |
ls -la
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain $(cat rust-toolchain)
echo "##vso[task.prependpath]$HOME/.cargo/bin"
displayName: 'Install Rust'
- script: |
rustc -Vv
cargo -V
displayName: Query rust and cargo versions
- script : cargo build --all-features
displayName: 'Build project'

View File

@@ -1,11 +1,15 @@
use std::process::Command;
fn main() {
read_git_info().expect("Unable to read Git info");
read_git_info().ok();
}
fn run(args: &[&str]) -> Result<String, std::io::Error> {
let out = Command::new(args[0]).args(&args[1..]).output()?;
if !out.status.success() {
use std::io::{Error, ErrorKind};
return Err(Error::new(ErrorKind::Other, "Command not successful"));
}
Ok(String::from_utf8(out.stdout).unwrap().trim().to_string())
}
@@ -13,8 +17,10 @@ fn run(args: &[&str]) -> Result<String, std::io::Error> {
fn read_git_info() -> Result<(), std::io::Error> {
// The exact tag for the current commit, can be empty when
// the current commit doesn't have an associated tag
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"])?;
println!("cargo:rustc-env=GIT_EXACT_TAG={}", exact_tag);
let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok();
if let Some(ref exact) = exact_tag {
println!("cargo:rustc-env=GIT_EXACT_TAG={}", exact);
}
// The last available tag, equal to exact_tag when
// the current commit is tagged
@@ -27,13 +33,25 @@ fn read_git_info() -> Result<(), std::io::Error> {
// The current git commit hash
let rev = run(&["git", "rev-parse", "HEAD"])?;
let rev_short = rev.get(..12).unwrap_or_default();
let rev_short = rev.get(..8).unwrap_or_default();
println!("cargo:rustc-env=GIT_REV={}", rev_short);
// Combined version
let version = if let Some(exact) = exact_tag {
exact
} else if &branch != "master" {
format!("{}-{} ({})", last_tag, rev_short, branch)
} else {
format!("{}-{}", last_tag, rev_short)
};
println!("cargo:rustc-env=GIT_VERSION={}", version);
// To access these values, use:
// env!("GIT_EXACT_TAG")
// env!("GIT_LAST_TAG")
// env!("GIT_BRANCH")
// env!("GIT_REV")
// env!("GIT_VERSION")
Ok(())
}

View File

@@ -1 +1 @@
nightly-2019-01-08
nightly-2019-03-14

View File

@@ -1,68 +1,197 @@
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::api::{JsonResult, JsonUpcase};
use rocket::http::{Cookie, Cookies, SameSite};
use rocket::request::{self, FlashMessage, Form, FromRequest, Request};
use rocket::response::{content::Html, Flash, Redirect};
use rocket::{Outcome, Route};
use rocket_contrib::json::Json;
use crate::api::{ApiResult, EmptyResult};
use crate::auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp};
use crate::config::ConfigBuilder;
use crate::db::{models::*, DbConn};
use crate::error::Error;
use crate::mail;
use crate::CONFIG;
use crate::db::models::*;
use crate::db::DbConn;
use crate::mail;
use rocket::request::{self, FromRequest, Request};
use rocket::{Outcome, Route};
pub fn routes() -> Vec<Route> {
routes![get_users, invite_user, delete_user]
if CONFIG.admin_token().is_none() && !CONFIG.disable_admin_token() {
return routes![admin_disabled];
}
routes![
admin_login,
post_admin_login,
admin_page,
invite_user,
delete_user,
deauth_user,
update_revision_users,
post_config,
delete_config,
]
}
#[get("/")]
fn admin_disabled() -> &'static str {
"The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"
}
const COOKIE_NAME: &str = "BWRS_ADMIN";
const ADMIN_PATH: &str = "/admin";
const BASE_TEMPLATE: &str = "admin/base";
const VERSION: Option<&str> = option_env!("GIT_VERSION");
#[get("/", rank = 2)]
fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> {
// If there is an error, show it
let msg = flash.map(|msg| format!("{}: {}", msg.name(), msg.msg()));
let json = json!({"page_content": "admin/login", "version": VERSION, "error": msg});
// Return the page
let text = CONFIG.render_template(BASE_TEMPLATE, &json)?;
Ok(Html(text))
}
#[derive(FromForm)]
struct LoginForm {
token: String,
}
#[post("/", data = "<data>")]
fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -> Result<Redirect, Flash<Redirect>> {
let data = data.into_inner();
// If the token is invalid, redirect to login page
if !_validate_token(&data.token) {
error!("Invalid admin token. IP: {}", ip.ip);
Err(Flash::error(
Redirect::to(ADMIN_PATH),
"Invalid admin token, please try again.",
))
} else {
// If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);
let cookie = Cookie::build(COOKIE_NAME, jwt)
.path(ADMIN_PATH)
.max_age(chrono::Duration::minutes(20))
.same_site(SameSite::Strict)
.http_only(true)
.finish();
cookies.add(cookie);
Ok(Redirect::to(ADMIN_PATH))
}
}
fn _validate_token(token: &str) -> bool {
match CONFIG.admin_token().as_ref() {
None => false,
Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()),
}
}
#[derive(Serialize)]
struct AdminTemplateData {
page_content: String,
version: Option<&'static str>,
users: Vec<Value>,
config: Value,
}
impl AdminTemplateData {
fn new(users: Vec<Value>) -> Self {
Self {
page_content: String::from("admin/page"),
version: VERSION,
users,
config: CONFIG.prepare_json(),
}
}
fn render(self) -> Result<String, Error> {
CONFIG.render_template(BASE_TEMPLATE, &self)
}
}
#[get("/", rank = 1)]
fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
let text = AdminTemplateData::new(users_json).render()?;
Ok(Html(text))
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct InviteData {
Email: String,
}
#[get("/users")]
fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult {
let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
Ok(Json(Value::Array(users_json)))
email: String,
}
#[post("/invite", data = "<data>")]
fn invite_user(data: JsonUpcase<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
let data: InviteData = data.into_inner().data;
let email = data.Email.clone();
if User::find_by_mail(&data.Email, &conn).is_some() {
fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> EmptyResult {
let data: InviteData = data.into_inner();
let email = data.email.clone();
if User::find_by_mail(&data.email, &conn).is_some() {
err!("User already exists")
}
if !CONFIG.invitations_allowed {
if !CONFIG.invitations_allowed() {
err!("Invitations are not allowed")
}
if let Some(ref mail_config) = CONFIG.mail {
if CONFIG.mail_enabled() {
let mut user = User::new(email);
user.save(&conn)?;
let org_name = "bitwarden_rs";
mail::send_invite(&user.email, &user.uuid, None, None, &org_name, None, mail_config)?;
mail::send_invite(&user.email, &user.uuid, None, None, &org_name, None)
} else {
let mut invitation = Invitation::new(data.Email);
invitation.save(&conn)?;
let invitation = Invitation::new(data.email);
invitation.save(&conn)
}
Ok(Json(json!({})))
}
#[post("/users/<uuid>/delete")]
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> JsonResult {
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let user = match User::find_by_uuid(&uuid, &conn) {
Some(user) => user,
None => err!("User doesn't exist"),
};
user.delete(&conn)?;
Ok(Json(json!({})))
user.delete(&conn)
}
#[post("/users/<uuid>/deauth")]
fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = match User::find_by_uuid(&uuid, &conn) {
Some(user) => user,
None => err!("User doesn't exist"),
};
Device::delete_all_by_user(&user.uuid, &conn)?;
user.reset_security_stamp();
user.save(&conn)
}
#[post("/users/update_revision")]
fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
User::update_all_revisions(&conn)
}
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();
CONFIG.update_config(data)
}
#[post("/config/delete")]
fn delete_config(_token: AdminToken) -> EmptyResult {
CONFIG.delete_user_config()
}
pub struct AdminToken {}
@@ -71,37 +200,29 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminToken {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
let config_token = match CONFIG.admin_token.as_ref() {
Some(token) => token,
None => err_handler!("Admin panel is disabled"),
if CONFIG.disable_admin_token() {
Outcome::Success(AdminToken {})
} else {
let mut cookies = request.cookies();
let access_token = match cookies.get(COOKIE_NAME) {
Some(cookie) => cookie.value(),
None => return Outcome::Forward(()), // If there is no cookie, redirect to login
};
// Get access_token
let access_token: &str = match request.headers().get_one("Authorization") {
Some(a) => match a.rsplit("Bearer ").next() {
Some(split) => split,
None => err_handler!("No access token provided"),
},
None => err_handler!("No access token provided"),
};
// TODO: What authentication to use?
// Option 1: Make it a config option
// Option 2: Generate random token, and
// Option 2a: Send it to admin email, like upstream
// Option 2b: Print in console or save to data dir, so admin can check
use crate::auth::ClientIp;
let ip = match request.guard::<ClientIp>() {
Outcome::Success(ip) => ip,
Outcome::Success(ip) => ip.ip,
_ => err_handler!("Error getting Client IP"),
};
if access_token != config_token {
err_handler!("Invalid admin token", format!("IP: {}.", ip.ip))
if decode_admin(access_token).is_err() {
// Remove admin cookie
cookies.remove(Cookie::named(COOKIE_NAME));
error!("Invalid or expired admin JWT. IP: {}.", ip);
return Outcome::Forward(());
}
Outcome::Success(AdminToken {})
}
}
}

View File

@@ -4,7 +4,7 @@ use crate::db::models::*;
use crate::db::DbConn;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType};
use crate::auth::{decode_invite_jwt, Headers, InviteJWTClaims};
use crate::auth::{decode_invite, Headers};
use crate::mail;
use crate::CONFIG;
@@ -66,7 +66,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
}
if let Some(token) = data.Token {
let claims: InviteJWTClaims = decode_invite_jwt(&token)?;
let claims = decode_invite(&token)?;
if claims.email == data.Email {
user
} else {
@@ -79,14 +79,14 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
}
user
} else if CONFIG.signups_allowed {
} else if CONFIG.signups_allowed() {
err!("Account with this email already exists")
} else {
err!("Registration not allowed")
}
}
None => {
if CONFIG.signups_allowed || Invitation::take(&data.Email, &conn) {
if CONFIG.signups_allowed() || Invitation::take(&data.Email, &conn) {
User::new(data.Email.clone())
} else {
err!("Registration not allowed")
@@ -322,6 +322,7 @@ fn post_sstamp(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -
err!("Invalid password")
}
Device::delete_all_by_user(&user.uuid, &conn)?;
user.reset_security_stamp();
user.save(&conn)
}
@@ -419,9 +420,9 @@ fn password_hint(data: JsonUpcase<PasswordHintData>, conn: DbConn) -> EmptyResul
None => return Ok(()),
};
if let Some(ref mail_config) = CONFIG.mail {
mail::send_password_hint(&data.Email, hint, mail_config)?;
} else if CONFIG.show_password_hint {
if CONFIG.mail_enabled() {
mail::send_password_hint(&data.Email, hint)?;
} else if CONFIG.show_password_hint() {
if let Some(hint) = hint {
err!(format!("Your password hint is: {}", &hint));
} else {

View File

@@ -74,10 +74,10 @@ fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult {
let user_json = headers.user.to_json(&conn);
let folders = Folder::find_by_user(&headers.user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(|c| c.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_json: Vec<Value> = collections.iter().map(|c| c.to_json()).collect();
let collections_json: Vec<Value> = collections.iter().map(Collection::to_json).collect();
let ciphers = Cipher::find_by_user(&headers.user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers
@@ -221,6 +221,10 @@ pub fn update_cipher_from_data(
nt: &Notify,
ut: UpdateType,
) -> EmptyResult {
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.OrganizationId {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
if let Some(org_id) = data.OrganizationId {
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"),
@@ -300,10 +304,13 @@ pub fn update_cipher_from_data(
cipher.password_history = data.PasswordHistory.map(|f| f.to_string());
cipher.save(&conn)?;
cipher.move_to_folder(data.FolderId, &headers.user.uuid, &conn)?;
if ut != UpdateType::None {
nt.send_cipher_update(ut, &cipher, &cipher.update_users_revision(&conn));
}
cipher.move_to_folder(data.FolderId, &headers.user.uuid, &conn)
Ok(())
}
use super::folders::FolderData;
@@ -346,25 +353,18 @@ fn post_ciphers_import(data: JsonUpcase<ImportData>, headers: Headers, conn: DbC
}
// Read and create the ciphers
for (index, cipher_data) in data.Ciphers.into_iter().enumerate() {
for (index, mut cipher_data) in data.Ciphers.into_iter().enumerate() {
let folder_uuid = relations_map.get(&index).map(|i| folders[*i].uuid.clone());
cipher_data.FolderId = folder_uuid;
let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone());
update_cipher_from_data(
&mut cipher,
cipher_data,
&headers,
false,
&conn,
&nt,
UpdateType::CipherCreate,
)?;
cipher.move_to_folder(folder_uuid, &headers.user.uuid.clone(), &conn)?;
update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::None)?;
}
let mut user = headers.user;
user.update_revision(&conn)
user.update_revision(&conn)?;
nt.send_user_update(UpdateType::Vault, &user);
Ok(())
}
#[put("/ciphers/<uuid>/admin", data = "<data>")]
@@ -608,7 +608,7 @@ fn share_cipher_by_uuid(
None => err!("Invalid collection ID provided"),
Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn) {
CollectionCipher::save(&cipher.uuid.clone(), &collection.uuid, &conn)?;
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn)?;
shared_to_collection = true;
} else {
err!("No rights to modify the collection")
@@ -632,7 +632,14 @@ fn share_cipher_by_uuid(
}
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn) -> JsonResult {
fn post_attachment(
uuid: String,
data: Data,
content_type: &ContentType,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> JsonResult {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
@@ -646,13 +653,13 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
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 base_path = Path::new(&CONFIG.attachments_folder()).join(&cipher.uuid);
let mut attachment_key = None;
Multipart::with_body(data.open(), boundary)
.foreach_entry(|mut field| {
match field.headers.name.as_str() {
match &*field.headers.name {
"key" => {
use std::io::Read;
let mut key_buffer = String::new();
@@ -692,6 +699,8 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
})
.expect("Error processing multipart data");
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
}
@@ -702,8 +711,9 @@ fn post_attachment_admin(
content_type: &ContentType,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> JsonResult {
post_attachment(uuid, data, content_type, headers, conn)
post_attachment(uuid, data, content_type, headers, conn, nt)
}
#[post(
@@ -721,7 +731,7 @@ fn post_attachment_share(
nt: Notify,
) -> JsonResult {
_delete_cipher_attachment_by_id(&uuid, &attachment_id, &headers, &conn, &nt)?;
post_attachment(uuid, data, content_type, headers, conn)
post_attachment(uuid, data, content_type, headers, conn, nt)
}
#[post("/ciphers/<uuid>/attachment/<attachment_id>/delete-admin")]
@@ -808,56 +818,55 @@ fn delete_cipher_selected_post(data: JsonUpcase<Value>, headers: Headers, conn:
delete_cipher_selected(data, headers, conn, nt)
}
#[post("/ciphers/move", data = "<data>")]
fn move_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let data = data.into_inner().data;
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct MoveCipherData {
FolderId: Option<String>,
Ids: Vec<String>,
}
let folder_id = match data.get("FolderId") {
Some(folder_id) => match folder_id.as_str() {
Some(folder_id) => match Folder::find_by_uuid(folder_id, &conn) {
#[post("/ciphers/move", data = "<data>")]
fn move_cipher_selected(data: JsonUpcase<MoveCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let data = data.into_inner().data;
let user_uuid = headers.user.uuid;
if let Some(ref folder_id) = data.FolderId {
match Folder::find_by_uuid(folder_id, &conn) {
Some(folder) => {
if folder.user_uuid != headers.user.uuid {
if folder.user_uuid != user_uuid {
err!("Folder is not owned by user")
}
Some(folder.uuid)
}
None => err!("Folder doesn't exist"),
},
None => err!("Folder id provided in wrong format"),
},
None => None,
};
}
}
let uuids = match data.get("Ids") {
Some(ids) => match ids.as_array() {
Some(ids) => ids.iter().filter_map(Value::as_str),
None => err!("Posted ids field is not an array"),
},
None => err!("Request missing ids field"),
};
for uuid in uuids {
let mut cipher = match Cipher::find_by_uuid(uuid, &conn) {
for uuid in data.Ids {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
};
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn) {
if !cipher.is_accessible_to_user(&user_uuid, &conn) {
err!("Cipher is not accessible by user")
}
// Move cipher
cipher.move_to_folder(folder_id.clone(), &headers.user.uuid, &conn)?;
cipher.save(&conn)?;
cipher.move_to_folder(data.FolderId.clone(), &user_uuid, &conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &[user_uuid.clone()]);
}
Ok(())
}
#[put("/ciphers/move", data = "<data>")]
fn move_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
fn move_cipher_selected_put(
data: JsonUpcase<MoveCipherData>,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
move_cipher_selected(data, headers, conn, nt)
}
@@ -866,7 +875,7 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn, nt
let data: PasswordData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let user = headers.user;
let mut user = headers.user;
if !user.check_valid_password(&password_hash) {
err!("Invalid password")
@@ -875,15 +884,15 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn, nt
// Delete ciphers and their attachments
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
cipher.delete(&conn)?;
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
}
// Delete folders
for f in Folder::find_by_user(&user.uuid, &conn) {
f.delete(&conn)?;
nt.send_folder_update(UpdateType::FolderCreate, &f);
}
user.update_revision(&conn)?;
nt.send_user_update(UpdateType::Vault, &user);
Ok(())
}
@@ -929,6 +938,6 @@ fn _delete_cipher_attachment_by_id(
// Delete attachment
attachment.delete(&conn)?;
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
Ok(())
}

View File

@@ -25,7 +25,7 @@ pub fn routes() -> Vec<Route> {
fn get_folders(headers: Headers, conn: DbConn) -> JsonResult {
let folders = Folder::find_by_user(&headers.user.uuid, &conn);
let folders_json: Vec<Value> = folders.iter().map(|c| c.to_json()).collect();
let folders_json: Vec<Value> = folders.iter().map(Folder::to_json).collect();
Ok(Json(json!({
"Data": folders_json,

View File

@@ -11,6 +11,7 @@ pub fn routes() -> Vec<Route> {
get_eq_domains,
post_eq_domains,
put_eq_domains,
hibp_breach,
];
let mut routes = Vec::new();
@@ -32,10 +33,10 @@ use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::db::DbConn;
use crate::api::{EmptyResult, JsonResult, JsonUpcase};
use crate::auth::Headers;
use crate::db::DbConn;
use crate::error::Error;
#[put("/devices/identifier/<uuid>/clear-token")]
fn clear_device_token(uuid: String) -> EmptyResult {
@@ -116,8 +117,8 @@ fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: Db
let mut user = headers.user;
use serde_json::to_string;
user.excluded_globals = to_string(&excluded_globals).unwrap_or("[]".to_string());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or("[]".to_string());
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
user.save(&conn)?;
@@ -128,3 +129,21 @@ fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: Db
fn put_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> JsonResult {
post_eq_domains(data, headers, conn)
}
#[get("/hibp/breach?<username>")]
fn hibp_breach(username: String) -> JsonResult {
let url = format!("https://haveibeenpwned.com/api/v2/breachedaccount/{}", username);
let user_agent = "Bitwarden_RS";
use reqwest::{header::USER_AGENT, Client};
let res = Client::new().get(&url).header(USER_AGENT, user_agent).send()?;
// If we get a 404, return a 404, it means no breached accounts
if res.status() == 404 {
return Err(Error::empty().with_code(404));
}
let value: Value = res.error_for_status()?.json()?;
Ok(Json(value))
}

View File

@@ -1,19 +1,16 @@
use rocket::request::Form;
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::api::{
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType,
};
use crate::auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders};
use crate::db::models::*;
use crate::db::DbConn;
use crate::CONFIG;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType};
use crate::auth::{decode_invite_jwt, AdminHeaders, Headers, InviteJWTClaims, OwnerHeaders};
use crate::mail;
use serde::{Deserialize, Deserializer};
use rocket::Route;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![
@@ -26,6 +23,7 @@ pub fn routes() -> Vec<Route> {
get_org_collections,
get_org_collection_detail,
get_collection_users,
put_collection_users,
put_organization,
post_organization,
post_organization_collections,
@@ -78,9 +76,9 @@ struct NewCollectionData {
fn create_organization(headers: Headers, data: JsonUpcase<OrgData>, conn: DbConn) -> JsonResult {
let data: OrgData = data.into_inner().data;
let mut org = Organization::new(data.Name, data.BillingEmail);
let org = Organization::new(data.Name, data.BillingEmail);
let mut user_org = UserOrganization::new(headers.user.uuid.clone(), org.uuid.clone());
let mut collection = Collection::new(org.uuid.clone(), data.CollectionName);
let collection = Collection::new(org.uuid.clone(), data.CollectionName);
user_org.key = data.Key;
user_org.access_all = true;
@@ -223,7 +221,7 @@ fn post_organization_collections(
None => err!("Can't find organization details"),
};
let mut collection = Collection::new(org.uuid.clone(), data.Name);
let collection = Collection::new(org.uuid.clone(), data.Name);
collection.save(&conn)?;
Ok(Json(collection.to_json()))
@@ -371,15 +369,44 @@ fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders,
.map(|col_user| {
UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn)
.unwrap()
.to_json_collection_user_details(col_user.read_only, &conn)
.to_json_collection_user_details(col_user.read_only)
})
.collect();
Ok(Json(json!({
"Data": user_list,
"Object": "list",
"ContinuationToken": null,
})))
Ok(Json(json!(user_list)))
}
#[put("/organizations/<org_id>/collections/<coll_id>/users", data = "<data>")]
fn put_collection_users(
org_id: String,
coll_id: String,
data: JsonUpcaseVec<CollectionData>,
_headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
// Get org and collection, check that collection is from org
if Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn).is_none() {
err!("Collection not found in Organization")
}
// Delete all the user-collections
CollectionUser::delete_all_by_collection(&coll_id, &conn)?;
// And then add all the received ones (except if the user has access_all)
for d in data.iter().map(|d| &d.data) {
let user = match UserOrganization::find_by_uuid(&d.Id, &conn) {
Some(u) => u,
None => err!("User is not part of organization"),
};
if user.access_all {
continue;
}
CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, &conn)?;
}
Ok(())
}
#[derive(FromForm)]
@@ -415,14 +442,6 @@ fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonRe
})))
}
fn deserialize_collections<'de, D>(deserializer: D) -> Result<Vec<CollectionData>, D::Error>
where
D: Deserializer<'de>,
{
// Deserialize null to empty Vec
Deserialize::deserialize(deserializer).or(Ok(vec![]))
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct CollectionData {
@@ -435,8 +454,7 @@ struct CollectionData {
struct InviteData {
Emails: Vec<String>,
Type: NumberOrString,
#[serde(deserialize_with = "deserialize_collections")]
Collections: Vec<CollectionData>,
Collections: Option<Vec<CollectionData>>,
AccessAll: Option<bool>,
}
@@ -454,18 +472,19 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
}
for email in data.Emails.iter() {
let mut user_org_status = match CONFIG.mail {
Some(_) => UserOrgStatus::Invited as i32,
None => UserOrgStatus::Accepted as i32, // Automatically mark user as accepted if no email invites
let mut 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 user = match User::find_by_mail(&email, &conn) {
None => {
if !CONFIG.invitations_allowed {
if !CONFIG.invitations_allowed() {
err!(format!("User email does not exist: {}", email))
}
if CONFIG.mail.is_none() {
let mut invitation = Invitation::new(email.clone());
if !CONFIG.mail_enabled() {
let invitation = Invitation::new(email.clone());
invitation.save(&conn)?;
}
@@ -491,7 +510,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
// If no accessAll, add the collections received
if !access_all {
for col in &data.Collections {
for col in data.Collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"),
Some(collection) => {
@@ -503,7 +522,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
new_user.save(&conn)?;
if let Some(ref mail_config) = CONFIG.mail {
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"),
@@ -516,7 +535,6 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
Some(new_user.uuid),
&org_name,
Some(headers.user.email.clone()),
mail_config,
)?;
}
}
@@ -526,11 +544,11 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
#[post("/organizations/<org_id>/users/<user_org>/reinvite")]
fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
if !CONFIG.invitations_allowed {
if !CONFIG.invitations_allowed() {
err!("Invitations are not allowed.")
}
if CONFIG.mail.is_none() {
if !CONFIG.mail_enabled() {
err!("SMTP is not configured.")
}
@@ -553,7 +571,7 @@ fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn:
None => err!("Error looking up organization."),
};
if let Some(ref mail_config) = CONFIG.mail {
if CONFIG.mail_enabled() {
mail::send_invite(
&user.email,
&user.uuid,
@@ -561,10 +579,9 @@ fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn:
Some(user_org.uuid),
&org_name,
Some(headers.user.email),
mail_config,
)?;
} else {
let mut invitation = Invitation::new(user.email.clone());
let invitation = Invitation::new(user.email.clone());
invitation.save(&conn)?;
}
@@ -582,7 +599,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
let data: AcceptData = data.into_inner().data;
let token = &data.Token;
let claims: InviteJWTClaims = decode_invite_jwt(&token)?;
let claims = decode_invite(&token)?;
match User::find_by_mail(&claims.email, &conn) {
Some(_) => {
@@ -605,7 +622,7 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
None => err!("Invited user not found"),
}
if let Some(ref mail_config) = CONFIG.mail {
if CONFIG.mail_enabled() {
let mut org_name = String::from("bitwarden_rs");
if let Some(org_id) = &claims.org_id {
org_name = match Organization::find_by_uuid(&org_id, &conn) {
@@ -615,10 +632,10 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
};
if let Some(invited_by_email) = &claims.invited_by_email {
// User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name, mail_config)?;
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name)?;
} else {
// User was invited from /admin, so they are automatically confirmed
mail::send_invite_confirmed(&claims.email, &org_name, mail_config)?;
mail::send_invite_confirmed(&claims.email, &org_name)?;
}
}
@@ -654,7 +671,7 @@ fn confirm_invite(
None => err!("Invalid key provided"),
};
if let Some(ref mail_config) = CONFIG.mail {
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."),
@@ -663,7 +680,7 @@ fn confirm_invite(
Some(user) => user.email,
None => err!("Error looking up user."),
};
mail::send_invite_confirmed(&address, &org_name, mail_config)?;
mail::send_invite_confirmed(&address, &org_name)?;
}
user_to_confirm.save(&conn)
@@ -683,8 +700,7 @@ fn get_user(org_id: String, org_user_id: String, _headers: AdminHeaders, conn: D
#[allow(non_snake_case)]
struct EditUserData {
Type: NumberOrString,
#[serde(deserialize_with = "deserialize_collections")]
Collections: Vec<CollectionData>,
Collections: Option<Vec<CollectionData>>,
AccessAll: bool,
}
@@ -749,7 +765,7 @@ fn edit_user(
// If no accessAll, add the collections received
if !data.AccessAll {
for col in &data.Collections {
for col in data.Collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"),
Some(collection) => {
@@ -835,7 +851,7 @@ fn post_org_import(
.Collections
.into_iter()
.map(|coll| {
let mut collection = Collection::new(org_id.clone(), coll.Name);
let collection = Collection::new(org_id.clone(), coll.Name);
if collection.save(&conn).is_err() {
err!("Failed to create Collection");
}

View File

@@ -3,30 +3,19 @@ use rocket_contrib::json::Json;
use serde_json;
use serde_json::Value;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{
models::{TwoFactor, TwoFactorType, User},
DbConn,
};
use crate::crypto;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::error::{Error, MapResult};
use rocket::Route;
#[cfg(feature = "enable_yubikey")]
fn yubi_routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put]
}
#[cfg(not(feature = "enable_yubikey"))]
fn yubi_routes() -> Vec<Route> {
Vec::new()
}
pub fn routes() -> Vec<Route> {
let mut routes = routes![
routes![
get_twofactor,
get_recover,
recover,
@@ -39,17 +28,16 @@ pub fn routes() -> Vec<Route> {
generate_u2f_challenge,
activate_u2f,
activate_u2f_put,
];
routes.append(&mut yubi_routes());
routes
generate_yubikey,
activate_yubikey,
activate_yubikey_put,
]
}
#[get("/two-factor")]
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
let twofactors_json: Vec<Value> = twofactors.iter().map(|c| c.to_json_list()).collect();
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_list).collect();
Ok(Json(json!({
"Data": twofactors_json,
@@ -61,13 +49,14 @@ fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
#[post("/two-factor/get-recover", data = "<data>")]
fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult {
let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
Ok(Json(json!({
"Code": headers.user.totp_recover,
"Code": user.totp_recover,
"Object": "twoFactorRecover"
})))
}
@@ -104,7 +93,7 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
// Remove all twofactors from the user
for twofactor in TwoFactor::find_by_user(&user.uuid, &conn) {
twofactor.delete(&conn).expect("Error deleting twofactor");
twofactor.delete(&conn)?;
}
// Remove the recovery code, not needed without twofactors
@@ -113,6 +102,14 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
Ok(Json(json!({})))
}
fn _generate_recover_code(user: &mut User, conn: &DbConn) {
if user.totp_recover.is_none() {
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
user.totp_recover = Some(totp_recover);
user.save(conn).ok();
}
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct DisableTwoFactorData {
@@ -124,15 +121,16 @@ struct DisableTwoFactorData {
fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let user = headers.user;
if !headers.user.check_valid_password(&password_hash) {
if !user.check_valid_password(&password_hash) {
err!("Invalid password");
}
let type_ = data.Type.into_i32().expect("Invalid type");
let type_ = data.Type.into_i32()?;
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn) {
twofactor.delete(&conn).expect("Error deleting twofactor");
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) {
twofactor.delete(&conn)?;
}
Ok(Json(json!({
@@ -150,13 +148,14 @@ fn disable_twofactor_put(data: JsonUpcase<DisableTwoFactorData>, headers: Header
#[post("/two-factor/get-authenticator", data = "<data>")]
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn);
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn);
let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data),
@@ -183,12 +182,11 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
let data: EnableAuthenticatorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let key = data.Key;
let token = match data.Token.into_i32() {
Some(n) => n as u64,
None => err!("Malformed token"),
};
let token = data.Token.into_i32()? as u64;
if !headers.user.check_valid_password(&password_hash) {
let mut user = headers.user;
if !user.check_valid_password(&password_hash) {
err!("Invalid password");
}
@@ -203,16 +201,13 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
}
let type_ = TwoFactorType::Authenticator;
let twofactor = TwoFactor::new(headers.user.uuid.clone(), type_, key.to_uppercase());
let twofactor = TwoFactor::new(user.uuid.clone(), type_, key.to_uppercase());
// Validate the token provided with the key
if !twofactor.check_totp_code(token) {
err!("Invalid totp code")
}
validate_totp_code(token, &twofactor.data)?;
let mut user = headers.user;
_generate_recover_code(&mut user, &conn);
twofactor.save(&conn).expect("Error saving twofactor");
twofactor.save(&conn)?;
Ok(Json(json!({
"Enabled": true,
@@ -226,12 +221,30 @@ fn activate_authenticator_put(data: JsonUpcase<EnableAuthenticatorData>, headers
activate_authenticator(data, headers, conn)
}
fn _generate_recover_code(user: &mut User, conn: &DbConn) {
if user.totp_recover.is_none() {
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
user.totp_recover = Some(totp_recover);
user.save(conn).ok();
pub fn validate_totp_code_str(totp_code: &str, secret: &str) -> EmptyResult {
let totp_code: u64 = match totp_code.parse() {
Ok(code) => code,
_ => err!("TOTP code is not a number"),
};
validate_totp_code(totp_code, secret)
}
pub fn validate_totp_code(totp_code: u64, secret: &str) -> EmptyResult {
use data_encoding::BASE32;
use oath::{totp_raw_now, HashType};
let decoded_secret = match BASE32.decode(secret.as_bytes()) {
Ok(s) => s,
Err(_) => err!("Invalid TOTP secret"),
};
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
if generated != totp_code {
err!("Invalid TOTP code");
}
Ok(())
}
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
@@ -243,29 +256,27 @@ use crate::CONFIG;
const U2F_VERSION: &str = "U2F_V2";
lazy_static! {
static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain);
static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain());
static ref U2F: U2f = U2f::new(APP_ID.clone());
}
#[post("/two-factor/get-u2f", data = "<data>")]
fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.domain_set {
if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. U2F disabled")
}
let data: PasswordData = data.into_inner().data;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
let user_uuid = &headers.user.uuid;
let u2f_type = TwoFactorType::U2f as i32;
let enabled = TwoFactor::find_by_user_and_type(user_uuid, u2f_type, &conn).is_some();
let (enabled, keys) = get_u2f_registrations(&headers.user.uuid, &conn)?;
let keys_json: Vec<Value> = keys.iter().map(U2FRegistration::to_json).collect();
Ok(Json(json!({
"Enabled": enabled,
"Keys": keys_json,
"Object": "twoFactorU2f"
})))
}
@@ -278,9 +289,8 @@ fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn
err!("Invalid password");
}
let user_uuid = &headers.user.uuid;
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fRegisterChallenge, &conn).challenge;
let _type = TwoFactorType::U2fRegisterChallenge;
let challenge = _create_u2f_challenge(&headers.user.uuid, _type, &conn).challenge;
Ok(Json(json!({
"UserId": headers.user.uuid,
@@ -293,10 +303,43 @@ fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct EnableU2FData {
Id: NumberOrString, // 1..5
Name: String,
MasterPasswordHash: String,
DeviceResponse: String,
}
// This struct is referenced from the U2F lib
// because it doesn't implement Deserialize
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(remote = "Registration")]
struct RegistrationDef {
key_handle: Vec<u8>,
pub_key: Vec<u8>,
attestation_cert: Option<Vec<u8>>,
}
#[derive(Serialize, Deserialize)]
struct U2FRegistration {
id: i32,
name: String,
#[serde(with = "RegistrationDef")]
reg: Registration,
counter: u32,
compromised: bool,
}
impl U2FRegistration {
fn to_json(&self) -> Value {
json!({
"Id": self.id,
"Name": self.name,
"Compromised": self.compromised,
})
}
}
// This struct is copied from the U2F lib
// to add an optional error code
#[derive(Deserialize)]
@@ -309,8 +352,8 @@ struct RegisterResponseCopy {
pub error_code: Option<NumberOrString>,
}
impl RegisterResponseCopy {
fn into_response(self) -> RegisterResponse {
impl Into<RegisterResponse> for RegisterResponseCopy {
fn into(self) -> RegisterResponse {
RegisterResponse {
registration_data: self.registration_data,
version: self.version,
@@ -322,22 +365,24 @@ impl RegisterResponseCopy {
#[post("/two-factor/u2f", data = "<data>")]
fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableU2FData = data.into_inner().data;
let mut user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
let tf_challenge =
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2fRegisterChallenge as i32, &conn);
let tf_type = TwoFactorType::U2fRegisterChallenge as i32;
let tf_challenge = match TwoFactor::find_by_user_and_type(&user.uuid, tf_type, &conn) {
Some(c) => c,
None => err!("Can't recover challenge"),
};
if let Some(tf_challenge) = tf_challenge {
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?;
tf_challenge.delete(&conn)?;
let response_copy: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?;
let response: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?;
let error_code = response_copy
let error_code = response
.error_code
.clone()
.map_or("0".into(), NumberOrString::into_string);
@@ -346,36 +391,29 @@ fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn)
err!("Error registering U2F token")
}
let response = response_copy.into_response();
let registration = U2F.register_response(challenge.clone(), response.into())?;
let full_registration = U2FRegistration {
id: data.Id.into_i32()?,
name: data.Name,
reg: registration,
compromised: false,
counter: 0,
};
let registration = U2F.register_response(challenge.clone(), response)?;
// TODO: Allow more than one U2F device
let mut registrations = Vec::new();
registrations.push(registration);
let mut regs = get_u2f_registrations(&user.uuid, &conn)?.1;
let tf_registration = TwoFactor::new(
headers.user.uuid.clone(),
TwoFactorType::U2f,
serde_json::to_string(&registrations).unwrap(),
);
tf_registration.save(&conn)?;
// TODO: Check that there is no repeat Id
regs.push(full_registration);
save_u2f_registrations(&user.uuid, &regs, &conn)?;
let mut user = headers.user;
_generate_recover_code(&mut user, &conn);
let keys_json: Vec<Value> = regs.iter().map(U2FRegistration::to_json).collect();
Ok(Json(json!({
"Enabled": true,
"Challenge": {
"UserId": user.uuid,
"AppId": APP_ID.to_string(),
"Challenge": challenge,
"Version": U2F_VERSION,
},
"Keys": keys_json,
"Object": "twoFactorU2f"
})))
} else {
err!("Can't recover challenge")
}
}
#[put("/two-factor/u2f", data = "<data>")]
@@ -393,52 +431,76 @@ fn _create_u2f_challenge(user_uuid: &str, type_: TwoFactorType, conn: &DbConn) -
challenge
}
// This struct is copied from the U2F lib
// because it doesn't implement Deserialize
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct RegistrationCopy {
pub key_handle: Vec<u8>,
pub pub_key: Vec<u8>,
pub attestation_cert: Option<Vec<u8>>,
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)
}
impl Into<Registration> for RegistrationCopy {
fn into(self) -> Registration {
Registration {
key_handle: self.key_handle,
pub_key: self.pub_key,
attestation_cert: self.attestation_cert,
fn get_u2f_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<U2FRegistration>), Error> {
let type_ = TwoFactorType::U2f as i32;
let (enabled, regs) = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) {
Some(tf) => (tf.enabled, tf.data),
None => return Ok((false, Vec::new())), // If no data, return empty list
};
let data = match serde_json::from_str(&regs) {
Ok(d) => d,
Err(_) => {
// If error, try old format
let mut old_regs = _old_parse_registrations(&regs);
if old_regs.len() != 1 {
err!("The old U2F format only allows one device")
}
// Convert to new format
let new_regs = vec![U2FRegistration {
id: 1,
name: "Unnamed U2F key".into(),
reg: old_regs.remove(0),
compromised: false,
counter: 0,
}];
// Save new format
save_u2f_registrations(user_uuid, &new_regs, &conn)?;
new_regs
}
};
Ok((enabled, data))
}
fn _parse_registrations(registations: &str) -> Vec<Registration> {
let registrations_copy: Vec<RegistrationCopy> =
serde_json::from_str(registations).expect("Can't parse RegistrationCopy data");
fn _old_parse_registrations(registations: &str) -> Vec<Registration> {
#[derive(Deserialize)]
struct Helper(#[serde(with = "RegistrationDef")] Registration);
registrations_copy.into_iter().map(Into::into).collect()
let regs: Vec<Value> = serde_json::from_str(registations).expect("Can't parse Registration data");
regs.into_iter()
.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> {
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fLoginChallenge, conn);
let type_ = TwoFactorType::U2f as i32;
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) {
Some(tf) => tf,
None => err!("No U2F devices registered"),
};
let registrations: Vec<_> = get_u2f_registrations(user_uuid, conn)?
.1
.into_iter()
.map(|r| r.reg)
.collect();
let registrations = _parse_registrations(&twofactor.data);
let signed_request: U2fSignRequest = U2F.sign_request(challenge, registrations);
if registrations.is_empty() {
err!("No U2F devices registered")
}
Ok(signed_request)
Ok(U2F.sign_request(challenge, registrations))
}
pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult {
let challenge_type = TwoFactorType::U2fLoginChallenge as i32;
let u2f_type = TwoFactorType::U2f as i32;
let tf_challenge = TwoFactor::find_by_user_and_type(user_uuid, challenge_type, &conn);
let challenge = match tf_challenge {
@@ -449,28 +511,30 @@ pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> Emp
}
None => err!("Can't recover login challenge"),
};
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, u2f_type, conn) {
Some(tf) => tf,
None => err!("No U2F devices registered"),
};
let registrations = _parse_registrations(&twofactor.data);
let response: SignResponse = serde_json::from_str(response)?;
let mut registrations = get_u2f_registrations(user_uuid, conn)?.1;
if registrations.is_empty() {
err!("No U2F devices registered")
}
let mut _counter: u32 = 0;
for registration in registrations {
let response = U2F.sign_response(challenge.clone(), registration, response.clone(), _counter);
for reg in &mut registrations {
let response = U2F.sign_response(challenge.clone(), reg.reg.clone(), response.clone(), reg.counter);
match response {
Ok(new_counter) => {
_counter = new_counter;
info!("O {:#}", new_counter);
reg.counter = new_counter;
save_u2f_registrations(user_uuid, &registrations, &conn)?;
return Ok(());
}
Err(u2f::u2ferror::U2fError::CounterTooLow) => {
reg.compromised = true;
save_u2f_registrations(user_uuid, &registrations, &conn)?;
err!("This device might be compromised!");
}
Err(e) => {
info!("E {:#}", e);
break;
warn!("E {:#}", e);
// break;
}
}
}
@@ -496,33 +560,13 @@ pub struct YubikeyMetadata {
pub Nfc: bool,
}
#[cfg(feature = "enable_yubikey")]
use yubico::{config::Config, Yubico};
use yubico::config::Config;
use yubico::Yubico;
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let mut yubikeys: Vec<String> = Vec::new();
let data_keys = [&data.Key1, &data.Key2, &data.Key3, &data.Key4, &data.Key5];
if data.Key1.is_some() {
yubikeys.push(data.Key1.as_ref().unwrap().to_owned());
}
if data.Key2.is_some() {
yubikeys.push(data.Key2.as_ref().unwrap().to_owned());
}
if data.Key3.is_some() {
yubikeys.push(data.Key3.as_ref().unwrap().to_owned());
}
if data.Key4.is_some() {
yubikeys.push(data.Key4.as_ref().unwrap().to_owned());
}
if data.Key5.is_some() {
yubikeys.push(data.Key5.as_ref().unwrap().to_owned());
}
yubikeys
data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
}
fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value {
@@ -535,42 +579,40 @@ fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value {
result
}
#[cfg(feature = "enable_yubikey")]
fn verify_yubikey_otp(otp: String) -> JsonResult {
if !CONFIG.yubico_cred_set {
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
}
let yubico = Yubico::new();
let config = Config::default()
.set_client_id(CONFIG.yubico_client_id.to_owned())
.set_key(CONFIG.yubico_secret_key.to_owned());
let result = match CONFIG.yubico_server {
Some(ref server) => yubico.verify(otp, config.set_api_hosts(vec![server.to_owned()])),
None => yubico.verify(otp, config),
};
match result {
Ok(_answer) => Ok(Json(json!({}))),
Err(_e) => err!("Failed to verify OTP"),
fn get_yubico_credentials() -> Result<(String, String), Error> {
match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
(Some(id), Some(secret)) => Ok((id, secret)),
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
}
}
#[cfg(feature = "enable_yubikey")]
fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let yubico = Yubico::new();
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
match CONFIG.yubico_server() {
Some(server) => yubico.verify(otp, config.set_api_hosts(vec![server])),
None => yubico.verify(otp, config),
}
.map_res("Failed to verify OTP")
.and(Ok(()))
}
#[post("/two-factor/get-yubikey", data = "<data>")]
fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.yubico_cred_set {
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled")
}
// Make sure the credentials are set
get_yubico_credentials()?;
let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
let user_uuid = &headers.user.uuid;
let user_uuid = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32;
let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &conn);
@@ -593,21 +635,20 @@ fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbCo
}
}
#[cfg(feature = "enable_yubikey")]
#[post("/two-factor/yubikey", data = "<data>")]
fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner().data;
let mut user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password");
}
// Check if we already have some data
let yubikey_data = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::YubiKey as i32, &conn);
if let Some(yubikey_data) = yubikey_data {
yubikey_data.delete(&conn)?;
}
let mut yubikey_data = match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn) {
Some(data) => data,
None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()),
};
let yubikeys = parse_yubikeys(&data);
@@ -625,11 +666,7 @@ fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn:
continue;
}
let result = verify_yubikey_otp(yubikey.to_owned());
if let Err(_e) = result {
err!("Invalid Yubikey OTP provided");
}
verify_yubikey_otp(yubikey.to_owned()).map_res("Invalid Yubikey OTP provided")?;
}
let yubikey_ids: Vec<String> = yubikeys.into_iter().map(|x| (&x[..12]).to_owned()).collect();
@@ -639,12 +676,10 @@ fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn:
Nfc: data.Nfc,
};
let yubikey_registration = TwoFactor::new(
headers.user.uuid.clone(),
TwoFactorType::YubiKey,
serde_json::to_string(&yubikey_metadata).unwrap(),
);
yubikey_registration.save(&conn)?;
yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap();
yubikey_data.save(&conn)?;
_generate_recover_code(&mut user, &conn);
let mut result = jsonify_yubikeys(yubikey_metadata.Keys);
@@ -655,32 +690,17 @@ fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn:
Ok(Json(result))
}
#[cfg(feature = "enable_yubikey")]
#[put("/two-factor/yubikey", data = "<data>")]
fn activate_yubikey_put(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
activate_yubikey(data, headers, conn)
}
#[cfg(not(feature = "enable_yubikey"))]
pub fn validate_yubikey_login(_: &str, _: &str, _: &DbConn) -> EmptyResult {
err!("Yubikey functionality is disabled. If you are using AArch64, check #262")
}
#[cfg(feature = "enable_yubikey")]
pub fn validate_yubikey_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult {
pub fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult {
if response.len() != 44 {
err!("Invalid Yubikey OTP length");
}
let yubikey_type = TwoFactorType::YubiKey as i32;
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &conn) {
Some(tf) => tf,
None => err!("No YubiKey devices registered"),
};
let yubikey_metadata: YubikeyMetadata =
serde_json::from_str(&twofactor.data).expect("Can't parse Yubikey Metadata");
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let response_id = &response[..12];
if !yubikey_metadata.Keys.contains(&response_id.to_owned()) {

View File

@@ -1,14 +1,19 @@
use std::error::Error;
use std::fs::{create_dir_all, remove_file, symlink_metadata, File};
use std::io::prelude::*;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use rocket::http::ContentType;
use rocket::response::Content;
use rocket::Route;
use reqwest;
use reqwest::{header::HeaderMap, Client, Response};
use rocket::http::Cookie;
use regex::Regex;
use soup::prelude::*;
use crate::error::Error;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
@@ -17,31 +22,72 @@ pub fn routes() -> Vec<Route> {
const FALLBACK_ICON: &[u8; 344] = include_bytes!("../static/fallback-icon.png");
const ALLOWED_CHARS: &str = "_-.";
lazy_static! {
// Reuse the client between requests
static ref CLIENT: Client = Client::builder()
.gzip(true)
.timeout(Duration::from_secs(CONFIG.icon_download_timeout()))
.default_headers(_header_map())
.build()
.unwrap();
}
fn is_valid_domain(domain: &str) -> bool {
// Don't allow empty or too big domains or path traversal
if domain.is_empty() || domain.len() > 255 || domain.contains("..") {
return false;
}
// Only alphanumeric or specific characters
for c in domain.chars() {
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
return false;
}
}
true
}
#[get("/<domain>/icon.png")]
fn icon(domain: String) -> Content<Vec<u8>> {
let icon_type = ContentType::new("image", "x-icon");
// Validate the domain to avoid directory traversal attacks
if domain.contains('/') || domain.contains("..") {
if !is_valid_domain(&domain) {
warn!("Invalid domain: {:#?}", domain);
return Content(icon_type, FALLBACK_ICON.to_vec());
}
if let Some(blacklist) = CONFIG.icon_blacklist_regex() {
info!("Icon blacklist enabled: {:#?}", blacklist);
let regex = Regex::new(&blacklist).expect("Valid Regex");
if regex.is_match(&domain) {
warn!("Blacklisted domain: {:#?}", domain);
return Content(icon_type, FALLBACK_ICON.to_vec());
}
}
let icon = get_icon(&domain);
Content(icon_type, icon)
}
fn get_icon(domain: &str) -> Vec<u8> {
let path = format!("{}/{}.png", CONFIG.icon_cache_folder, domain);
let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain);
if let Some(icon) = get_cached_icon(&path) {
return icon;
}
let url = get_icon_url(&domain);
if CONFIG.disable_icon_download() {
return FALLBACK_ICON.to_vec();
}
// Get the icon, or fallback in case of error
match download_icon(&url) {
match download_icon(&domain) {
Ok(icon) => {
save_icon(&path, &icon);
icon
@@ -77,7 +123,7 @@ fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None
}
fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Box<Error>> {
fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let meta = symlink_metadata(path)?;
let modified = meta.modified()?;
let age = SystemTime::now().duration_since(modified)?;
@@ -87,7 +133,7 @@ fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Box<Error>> {
fn icon_is_negcached(path: &str) -> bool {
let miss_indicator = path.to_owned() + ".miss";
let expired = file_is_expired(&miss_indicator, CONFIG.icon_cache_negttl);
let expired = file_is_expired(&miss_indicator, CONFIG.icon_cache_negttl());
match expired {
// No longer negatively cached, drop the marker
@@ -110,34 +156,234 @@ fn mark_negcache(path: &str) {
}
fn icon_is_expired(path: &str) -> bool {
let expired = file_is_expired(path, CONFIG.icon_cache_ttl);
let expired = file_is_expired(path, CONFIG.icon_cache_ttl());
expired.unwrap_or(true)
}
fn get_icon_url(domain: &str) -> String {
if CONFIG.local_icon_extractor {
format!("http://{}/favicon.ico", domain)
} else {
format!("https://icons.bitwarden.com/{}/icon.png", domain)
#[derive(Debug)]
struct Icon {
priority: u8,
href: String,
}
impl Icon {
fn new(priority: u8, href: String) -> Self {
Self { href, priority }
}
}
fn download_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
info!("Downloading icon for {}...", url);
let mut res = reqwest::get(url)?;
/// Returns a Result/Tuple which holds a Vector IconList and a string which holds the cookies from the last response.
/// There will always be a result with a string which will contain https://example.com/favicon.ico and an empty string for the cookies.
/// This does not mean that that location does exists, but it is the default location browser use.
///
/// # Argument
/// * `domain` - A string which holds the domain with extension.
///
/// # Example
/// ```
/// let (mut iconlist, cookie_str) = get_icon_url("github.com")?;
/// let (mut iconlist, cookie_str) = get_icon_url("gitlab.com")?;
/// ```
fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
// Default URL with secure and insecure schemes
let ssldomain = format!("https://{}", domain);
let httpdomain = format!("http://{}", domain);
res = res.error_for_status()?;
// Create the iconlist
let mut iconlist: Vec<Icon> = Vec::new();
let mut buffer: Vec<u8> = vec![];
// 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 {
// Extract the URL from the respose in case redirects occured (like @ gitlab.com)
let url = content.url().clone();
let raw_cookies = content.headers().get_all("set-cookie");
cookie_str = raw_cookies
.iter()
.map(|raw_cookie| {
let cookie = Cookie::parse(raw_cookie.to_str().unwrap_or_default()).unwrap();
format!("{}={}; ", cookie.name(), cookie.value())
})
.collect::<String>();
// 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()));
let soup = Soup::from_reader(content)?;
// Search for and filter
let favicons = soup
.tag("link")
.attr("rel", Regex::new(r"icon$|apple.*icon")?) // Only use icon rels
.attr("href", Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$")?) // Only allow specific extensions
.find_all();
// Loop through all the found icons and determine it's priority
for favicon in favicons {
let sizes = favicon.get("sizes");
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 {
// Add the default favicon.ico to the list with just the given domain
iconlist.push(Icon::new(35, format!("{}/favicon.ico", ssldomain)));
}
// Sort the iconlist by 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
Ok((iconlist, cookie_str))
}
fn get_page(url: &str) -> Result<Response, Error> {
get_page_with_cookies(url, "")
}
fn get_page_with_cookies(url: &str, cookie_str: &str) -> Result<Response, Error> {
CLIENT
.get(url)
.header("cookie", cookie_str)
.send()?
.error_for_status()
.map_err(Into::into)
}
/// Returns a Integer with the priority of the type of the icon which to prefer.
/// The lower the number the better.
///
/// # Arguments
/// * `href` - A string which holds the href value or relative path.
/// * `sizes` - The size of the icon if available as a <width>x<height> value like 32x32.
///
/// # Example
/// ```
/// 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", "");
/// ```
fn get_icon_priority(href: &str, sizes: Option<String>) -> u8 {
// Check if there is a dimension set
let (width, height) = parse_sizes(sizes);
// Check if there is a size given
if width != 0 && height != 0 {
// Only allow square dimensions
if width == height {
// Change priority by given size
if width == 32 {
1
} else if width == 64 {
2
} else if width >= 24 && width <= 128 {
3
} else if width == 16 {
4
} else {
5
}
// There are dimensions available, but the image is not a square
} else {
200
}
} else {
// Change priority by file extension
if href.ends_with(".png") {
10
} else if href.ends_with(".jpg") || href.ends_with(".jpeg") {
20
} else {
30
}
}
}
/// Returns a Tuple with the width and hight as a seperate value extracted from the sizes attribute
/// It will return 0 for both values if no match has been found.
///
/// # Arguments
/// * `sizes` - The size of the icon if available as a <width>x<height> value like 32x32.
///
/// # Example
/// ```
/// let (width, height) = parse_sizes("64x64"); // (64, 64)
/// let (width, height) = parse_sizes("x128x128"); // (128, 128)
/// let (width, height) = parse_sizes("32"); // (0, 0)
/// ```
fn parse_sizes(sizes: Option<String>) -> (u16, u16) {
let mut width: u16 = 0;
let mut height: u16 = 0;
if let Some(sizes) = sizes {
match Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap().captures(sizes.trim()) {
None => {}
Some(dimensions) => {
if dimensions.len() >= 3 {
width = dimensions[1].parse::<u16>().unwrap_or_default();
height = dimensions[2].parse::<u16>().unwrap_or_default();
}
}
}
}
(width, height)
}
fn download_icon(domain: &str) -> Result<Vec<u8>, Error> {
let (iconlist, cookie_str) = get_icon_url(&domain)?;
let mut buffer = Vec::new();
for icon in iconlist.iter().take(5) {
match get_page_with_cookies(&icon.href, &cookie_str) {
Ok(mut res) => {
info!("Downloaded icon from {}", icon.href);
res.copy_to(&mut buffer)?;
break;
}
Err(_) => info!("Download failed for {}", icon.href),
};
}
if buffer.is_empty() {
err!("Empty response")
}
Ok(buffer)
}
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");
if let Ok(mut f) = File::create(path) {
f.write_all(icon).expect("Error writing icon file");
};
}
fn _header_map() -> HeaderMap {
// Set some default headers for the request.
// Use a browser like user-agent to make sure most websites will return there correct website.
use reqwest::header::*;
macro_rules! headers {
($( $name:ident : $value:literal),+ $(,)? ) => {
let mut headers = HeaderMap::new();
$( headers.insert($name, HeaderValue::from_static($value)); )+
headers
};
}
headers! {
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",
ACCEPT_LANGUAGE: "en-US,en;q=0.8",
CACHE_CONTROL: "no-cache",
PRAGMA: "no-cache",
ACCEPT: "text/html,application/xhtml+xml,application/xml; q=0.9,image/webp,image/apng,*/*;q=0.8",
}
}

View File

@@ -118,7 +118,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
None => Device::new(device_id, user.uuid.clone(), device_name, device_type),
};
let twofactor_token = twofactor_auth(&user.uuid, &data.clone(), &mut device, &conn)?;
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
// Common
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
@@ -151,66 +151,46 @@ fn twofactor_auth(
device: &mut Device,
conn: &DbConn,
) -> ApiResult<Option<String>> {
let twofactors_raw = TwoFactor::find_by_user(user_uuid, conn);
// Remove u2f challenge twofactors (impl detail)
let twofactors: Vec<_> = twofactors_raw.iter().filter(|tf| tf.type_ < 1000).collect();
let providers: Vec<_> = twofactors.iter().map(|tf| tf.type_).collect();
let twofactors = TwoFactor::find_by_user(user_uuid, conn);
// No twofactor token if twofactor is disabled
if twofactors.is_empty() {
return Ok(None);
}
let provider = data.two_factor_provider.unwrap_or(providers[0]); // If we aren't given a two factor provider, asume the first one
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.type_).collect();
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, asume the first one
let twofactor_code = match data.two_factor_token {
Some(ref code) => code,
None => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
None => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?),
};
let twofactor = twofactors.iter().filter(|tf| tf.type_ == provider).nth(0);
let selected_twofactor = twofactors.into_iter().filter(|tf| tf.type_ == selected_id).nth(0);
use crate::api::core::two_factor as _tf;
use crate::crypto::ct_eq;
let selected_data = _selected_data(selected_twofactor);
let mut remember = data.two_factor_remember.unwrap_or(0);
match TwoFactorType::from_i32(selected_id) {
Some(TwoFactorType::Authenticator) => _tf::validate_totp_code_str(twofactor_code, &selected_data?)?,
Some(TwoFactorType::U2f) => _tf::validate_u2f_login(user_uuid, twofactor_code, conn)?,
Some(TwoFactorType::YubiKey) => _tf::validate_yubikey_login(twofactor_code, &selected_data?)?,
match TwoFactorType::from_i32(provider) {
Some(TwoFactorType::Remember) => {
match device.twofactor_remember {
Some(ref remember) if remember == twofactor_code => return Ok(None), // No twofactor token needed here
_ => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
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
}
_ => err_json!(_json_err_twofactor(&twofactor_ids, user_uuid, conn)?),
}
}
Some(TwoFactorType::Authenticator) => {
let twofactor = match twofactor {
Some(tf) => tf,
None => err!("TOTP not enabled"),
};
let totp_code: u64 = match twofactor_code.parse() {
Ok(code) => code,
_ => err!("Invalid TOTP code"),
};
if !twofactor.check_totp_code(totp_code) {
err_json!(_json_err_twofactor(&providers, user_uuid, conn)?)
}
}
Some(TwoFactorType::U2f) => {
use crate::api::core::two_factor;
two_factor::validate_u2f_login(user_uuid, &twofactor_code, conn)?;
}
Some(TwoFactorType::YubiKey) => {
use crate::api::core::two_factor;
two_factor::validate_yubikey_login(user_uuid, twofactor_code, conn)?;
}
_ => err!("Invalid two factor provider"),
}
if data.two_factor_remember.unwrap_or(0) == 1 {
if !CONFIG.disable_2fa_remember() && remember == 1 {
Ok(Some(device.refresh_twofactor_remember()))
} else {
device.delete_twofactor_remember();
@@ -218,6 +198,13 @@ fn twofactor_auth(
}
}
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
match tf {
Some(tf) => Ok(tf.data),
None => err!("Two factor doesn't exist"),
}
}
fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> ApiResult<Value> {
use crate::api::core::two_factor;
@@ -234,7 +221,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
match TwoFactorType::from_i32(*provider) {
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::U2f) if CONFIG.domain_set => {
Some(TwoFactorType::U2f) if CONFIG.domain_set() => {
let request = two_factor::generate_u2f_login(user_uuid, conn)?;
let mut challenge_list = Vec::new();

View File

@@ -23,6 +23,7 @@ pub type EmptyResult = ApiResult<()>;
use crate::util;
type JsonUpcase<T> = Json<util::UpCase<T>>;
type JsonUpcaseVec<T> = Json<Vec<util::UpCase<T>>>;
// Common structs representing JSON data received
#[derive(Deserialize)]
@@ -46,10 +47,13 @@ impl NumberOrString {
}
}
fn into_i32(self) -> Option<i32> {
fn into_i32(self) -> ApiResult<i32> {
use std::num::ParseIntError as PIE;
match self {
NumberOrString::Number(n) => Some(n),
NumberOrString::String(s) => s.parse().ok(),
NumberOrString::Number(n) => Ok(n),
NumberOrString::String(s) => s
.parse()
.map_err(|e: PIE| crate::Error::new("Can't convert to number", e.to_string())),
}
}
}

View File

@@ -25,7 +25,7 @@ fn negotiate(_headers: Headers, _conn: DbConn) -> JsonResult {
let conn_id = BASE64URL.encode(&crypto::get_random(vec![0u8; 16]));
let mut available_transports: Vec<JsonValue> = Vec::new();
if CONFIG.websocket_enabled {
if CONFIG.websocket_enabled() {
available_transports.push(json!({"transport":"WebSockets", "transferFormats":["Text","Binary"]}));
}
@@ -88,13 +88,10 @@ fn serialize(val: Value) -> Vec<u8> {
fn serialize_date(date: NaiveDateTime) -> Value {
let seconds: i64 = date.timestamp();
let nanos: i64 = date.timestamp_subsec_nanos() as i64;
let nanos: i64 = date.timestamp_subsec_nanos().into();
let timestamp = nanos << 34 | seconds;
use byteorder::{BigEndian, WriteBytesExt};
let mut bs = [0u8; 8];
bs.as_mut().write_i64::<BigEndian>(timestamp).expect("Unable to write");
let bs = timestamp.to_be_bytes();
// -1 is Timestamp
// https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
@@ -138,7 +135,7 @@ impl Handler for WSHandler {
// Validate the user
use crate::auth;
let claims = match auth::decode_jwt(access_token) {
let claims = match auth::decode_login(access_token) {
Ok(claims) => claims,
Err(_) => return Err(ws::Error::new(ws::ErrorKind::Internal, "Invalid access token provided")),
};
@@ -233,7 +230,7 @@ pub struct WebSocketUsers {
}
impl WebSocketUsers {
fn send_update(&self, user_uuid: &String, data: &[u8]) -> ws::Result<()> {
fn send_update(&self, user_uuid: &str, data: &[u8]) -> ws::Result<()> {
if let Some(user) = self.map.get(user_uuid) {
for sender in user.iter() {
sender.send(data)?;
@@ -243,7 +240,6 @@ impl WebSocketUsers {
}
// NOTE: The last modified date needs to be updated before calling these methods
#[allow(dead_code)]
pub fn send_user_update(&self, ut: UpdateType, user: &User) {
let data = create_update(
vec![
@@ -253,7 +249,7 @@ impl WebSocketUsers {
ut,
);
self.send_update(&user.uuid.clone(), &data).ok();
self.send_update(&user.uuid, &data).ok();
}
pub fn send_folder_update(&self, ut: UpdateType, folder: &Folder) {
@@ -328,6 +324,7 @@ fn create_ping() -> Vec<u8> {
}
#[allow(dead_code)]
#[derive(PartialEq)]
pub enum UpdateType {
CipherUpdate = 0,
CipherCreate = 1,
@@ -343,6 +340,8 @@ pub enum UpdateType {
SyncSettings = 10,
LogOut = 11,
None = 100,
}
use rocket::State;
@@ -352,9 +351,12 @@ pub fn start_notification_server() -> WebSocketUsers {
let factory = WSFactory::init();
let users = factory.users.clone();
if CONFIG.websocket_enabled {
if CONFIG.websocket_enabled() {
thread::spawn(move || {
WebSocket::new(factory).unwrap().listen(&CONFIG.websocket_url).unwrap();
WebSocket::new(factory)
.unwrap()
.listen((CONFIG.websocket_address().as_str(), CONFIG.websocket_port()))
.unwrap();
});
}

View File

@@ -2,18 +2,18 @@ use std::io;
use std::path::{Path, PathBuf};
use rocket::http::ContentType;
use rocket::request::Request;
use rocket::response::content::Content;
use rocket::response::{self, NamedFile, Responder};
use rocket::response::NamedFile;
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::util::Cached;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
if CONFIG.web_vault_enabled {
routes![web_index, app_id, web_files, admin_page, attachments, alive]
if CONFIG.web_vault_enabled() {
routes![web_index, app_id, web_files, attachments, alive]
} else {
routes![attachments, alive]
}
@@ -21,7 +21,9 @@ pub fn routes() -> Vec<Route> {
#[get("/")]
fn web_index() -> Cached<io::Result<NamedFile>> {
Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join("index.html")))
Cached::short(NamedFile::open(
Path::new(&CONFIG.web_vault_folder()).join("index.html"),
))
}
#[get("/app-id.json")]
@@ -35,7 +37,7 @@ fn app_id() -> Cached<Content<Json<Value>>> {
{
"version": { "major": 1, "minor": 0 },
"ids": [
&CONFIG.domain,
&CONFIG.domain(),
"ios:bundle-id:com.8bit.bitwarden",
"android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ]
}]
@@ -43,55 +45,14 @@ fn app_id() -> Cached<Content<Json<Value>>> {
))
}
const ADMIN_PAGE: &'static str = include_str!("../static/admin.html");
use rocket::response::content::Html;
#[get("/admin")]
fn admin_page() -> Cached<Html<&'static str>> {
Cached::short(Html(ADMIN_PAGE))
}
/* // Use this during Admin page development
#[get("/admin")]
fn admin_page() -> Cached<io::Result<NamedFile>> {
Cached::short(NamedFile::open("src/static/admin.html"))
}
*/
#[get("/<p..>", rank = 1)] // Only match this if the other routes don't match
#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
fn web_files(p: PathBuf) -> Cached<io::Result<NamedFile>> {
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p)))
}
struct Cached<R>(R, &'static str);
impl<R> Cached<R> {
fn long(r: R) -> Cached<R> {
// 7 days
Cached(r, "public, max-age=604800")
}
fn short(r: R) -> Cached<R> {
// 10 minutes
Cached(r, "public, max-age=600")
}
}
impl<'r, R: Responder<'r>> Responder<'r> for Cached<R> {
fn respond_to(self, req: &Request) -> response::Result<'r> {
match self.0.respond_to(req) {
Ok(mut res) => {
res.set_raw_header("Cache-Control", self.1);
Ok(res)
}
e @ Err(_) => e,
}
}
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)))
}
#[get("/attachments/<uuid>/<file..>")]
fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> {
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file))
NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file))
}
#[get("/alive")]

View File

@@ -5,6 +5,7 @@ use crate::util::read_file;
use chrono::{Duration, Utc};
use jsonwebtoken::{self, Algorithm, Header};
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use crate::error::{Error, MapResult};
@@ -14,21 +15,17 @@ const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
lazy_static! {
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2);
pub static ref JWT_ISSUER: String = CONFIG.domain.clone();
static ref JWT_HEADER: Header = Header::new(JWT_ALGORITHM);
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
pub static ref JWT_LOGIN_ISSUER: String = format!("{}|login", CONFIG.domain());
pub static ref JWT_INVITE_ISSUER: String = format!("{}|invite", CONFIG.domain());
pub static ref JWT_ADMIN_ISSUER: String = format!("{}|admin", CONFIG.domain());
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key()) {
Ok(key) => key,
Err(e) => panic!(
"Error loading private RSA Key from {}\n Error: {}",
CONFIG.private_rsa_key, e
),
Err(e) => panic!("Error loading private RSA Key.\n Error: {}", e),
};
static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key) {
static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key()) {
Ok(key) => key,
Err(e) => panic!(
"Error loading public RSA Key from {}\n Error: {}",
CONFIG.public_rsa_key, e
),
Err(e) => panic!("Error loading public RSA Key.\n Error: {}", e),
};
}
@@ -39,14 +36,14 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
}
}
pub fn decode_jwt(token: &str) -> Result<JWTClaims, Error> {
fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
let validation = jsonwebtoken::Validation {
leeway: 30, // 30 seconds
validate_exp: true,
validate_iat: false, // IssuedAt is the same as NotBefore
validate_nbf: true,
aud: None,
iss: Some(JWT_ISSUER.clone()),
iss: Some(issuer),
sub: None,
algorithms: vec![JWT_ALGORITHM],
};
@@ -55,30 +52,23 @@ pub fn decode_jwt(token: &str) -> Result<JWTClaims, Error> {
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation)
.map(|d| d.claims)
.map_res("Error decoding login JWT")
.map_res("Error decoding JWT")
}
pub fn decode_invite_jwt(token: &str) -> Result<InviteJWTClaims, Error> {
let validation = jsonwebtoken::Validation {
leeway: 30, // 30 seconds
validate_exp: true,
validate_iat: false, // IssuedAt is the same as NotBefore
validate_nbf: true,
aud: None,
iss: Some(JWT_ISSUER.clone()),
sub: None,
algorithms: vec![JWT_ALGORITHM],
};
pub fn decode_login(token: &str) -> Result<LoginJWTClaims, Error> {
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
}
let token = token.replace(char::is_whitespace, "");
pub fn decode_invite(token: &str) -> Result<InviteJWTClaims, Error> {
decode_jwt(token, JWT_INVITE_ISSUER.to_string())
}
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation)
.map(|d| d.claims)
.map_res("Error decoding invite JWT")
pub fn decode_admin(token: &str) -> Result<AdminJWTClaims, Error> {
decode_jwt(token, JWT_ADMIN_ISSUER.to_string())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JWTClaims {
pub struct LoginJWTClaims {
// Not before
pub nbf: i64,
// Expiration time
@@ -125,7 +115,8 @@ pub struct InviteJWTClaims {
pub invited_by_email: Option<String>,
}
pub fn generate_invite_claims(uuid: String,
pub fn generate_invite_claims(
uuid: String,
email: String,
org_id: Option<String>,
org_user_id: Option<String>,
@@ -135,7 +126,7 @@ pub fn generate_invite_claims(uuid: String,
InviteJWTClaims {
nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_ISSUER.to_string(),
iss: JWT_INVITE_ISSUER.to_string(),
sub: uuid.clone(),
email: email.clone(),
org_id: org_id.clone(),
@@ -144,6 +135,28 @@ pub fn generate_invite_claims(uuid: String,
}
}
#[derive(Debug, Serialize, Deserialize)]
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();
AdminJWTClaims {
nbf: time_now.timestamp(),
exp: (time_now + Duration::minutes(20)).timestamp(),
iss: JWT_ADMIN_ISSUER.to_string(),
sub: "admin_panel".to_string(),
}
}
//
// Bearer token authentication
//
@@ -166,8 +179,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
let headers = request.headers();
// Get host
let host = if CONFIG.domain_set {
CONFIG.domain.clone()
let host = if CONFIG.domain_set() {
CONFIG.domain()
} else if let Some(referer) = headers.get_one("Referer") {
referer.to_string()
} else {
@@ -203,7 +216,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
};
// Check JWT token is valid and get device and user from it
let claims: JWTClaims = match decode_jwt(access_token) {
let claims = match decode_login(access_token) {
Ok(claims) => claims,
Err(_) => err_handler!("Invalid claim"),
};

549
src/config.rs Normal file
View File

@@ -0,0 +1,549 @@
use std::process::exit;
use std::sync::RwLock;
use crate::error::Error;
use crate::util::get_env;
lazy_static! {
pub static ref CONFIG: Config = Config::load().unwrap_or_else(|e| {
println!("Error loading config:\n\t{:?}\n", e);
exit(12)
});
pub static ref CONFIG_FILE: String = get_env("CONFIG_FILE").unwrap_or_else(|| "data/config.json".into());
}
pub type Pass = String;
macro_rules! make_config {
($(
$(#[doc = $groupdoc:literal])?
$group:ident $(: $group_enabled:ident)? {
$(
$(#[doc = $doc:literal])+
$name:ident : $ty:ty, $editable:literal, $none_action:ident $(, $default:expr)?;
)+},
)+) => {
pub struct Config { inner: RwLock<Inner> }
struct Inner {
templates: Handlebars,
config: ConfigItems,
_env: ConfigBuilder,
_usr: ConfigBuilder,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ConfigBuilder {
$($(
#[serde(skip_serializing_if = "Option::is_none")]
$name: Option<$ty>,
)+)+
}
impl ConfigBuilder {
fn from_env() -> Self {
dotenv::from_path(".env").ok();
let mut builder = ConfigBuilder::default();
$($(
builder.$name = get_env(&stringify!($name).to_uppercase());
)+)+
builder
}
fn from_file(path: &str) -> Result<Self, Error> {
use crate::util::read_file_string;
let config_str = read_file_string(path)?;
serde_json::from_str(&config_str).map_err(Into::into)
}
/// Merges the values of both builders into a new builder.
/// If both have the same element, `other` wins.
fn merge(&self, other: &Self) -> Self {
let mut overrides = Vec::new();
let mut builder = self.clone();
$($(
if let v @Some(_) = &other.$name {
builder.$name = v.clone();
if self.$name.is_some() {
overrides.push(stringify!($name).to_uppercase());
}
}
)+)+
if !overrides.is_empty() {
// We can't use warn! here because logging isn't setup yet.
println!("[WARNING] The following environment variables are being overriden by the config file,");
println!("[WARNING] please use the admin panel to make changes to them:");
println!("[WARNING] {}\n", overrides.join(", "));
}
builder
}
/// Returns a new builder with all the elements from self,
/// except those that are equal in both sides
fn _remove(&self, other: &Self) -> Self {
let mut builder = ConfigBuilder::default();
$($(
if &self.$name != &other.$name {
builder.$name = self.$name.clone();
}
)+)+
builder
}
fn build(&self) -> ConfigItems {
let mut config = ConfigItems::default();
let _domain_set = self.domain.is_some();
$($(
config.$name = make_config!{ @build self.$name.clone(), &config, $none_action, $($default)? };
)+)+
config.domain_set = _domain_set;
config
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigItems { $($(pub $name: make_config!{@type $ty, $none_action}, )+)+ }
#[allow(unused)]
impl Config {
$($(
pub fn $name(&self) -> make_config!{@type $ty, $none_action} {
self.inner.read().unwrap().config.$name.clone()
}
)+)+
pub fn prepare_json(&self) -> serde_json::Value {
let (def, cfg) = {
let inner = &self.inner.read().unwrap();
(inner._env.build(), inner.config.clone())
};
fn _get_form_type(rust_type: &str) -> &'static str {
match rust_type {
"Pass" => "password",
"String" => "text",
"bool" => "checkbox",
_ => "number"
}
}
fn _get_doc(doc: &str) -> serde_json::Value {
let mut split = doc.split("|>").map(str::trim);
json!({
"name": split.next(),
"description": split.next()
})
}
json!([ $({
"group": stringify!($group),
"grouptoggle": stringify!($($group_enabled)?),
"groupdoc": make_config!{ @show $($groupdoc)? },
"elements": [
$( {
"editable": $editable,
"name": stringify!($name),
"value": cfg.$name,
"default": def.$name,
"type": _get_form_type(stringify!($ty)),
"doc": _get_doc(concat!($($doc),+)),
}, )+
]}, )+ ])
}
}
};
// Group or empty string
( @show ) => { "" };
( @show $lit:literal ) => { $lit };
// Wrap the optionals in an Option type
( @type $ty:ty, option) => { Option<$ty> };
( @type $ty:ty, $id:ident) => { $ty };
// Generate the values depending on none_action
( @build $value:expr, $config:expr, option, ) => { $value };
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
match $value {
Some(v) => v,
None => {
let f: &Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}
}
}};
}
//STRUCTURE:
// /// Short description (without this they won't appear on the list)
// group {
// /// Friendly Name |> Description (Optional)
// name: type, is_editable, none_action, <default_value (Optional)>
// }
//
// Where none_action applied when the value wasn't provided and can be:
// def: Use a default value
// auto: Value is auto generated based on other values
// option: Value is optional
make_config! {
folders {
/// Data folder |> Main data folder
data_folder: String, false, def, "data".to_string();
/// Database URL
database_url: String, false, auto, |c| format!("{}/{}", c.data_folder, "db.sqlite3");
/// Icon chache folder
icon_cache_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "icon_cache");
/// Attachments folder
attachments_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "attachments");
/// Templates folder
templates_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "templates");
/// Session JWT key
rsa_key_filename: String, false, auto, |c| format!("{}/{}", c.data_folder, "rsa_key");
/// Web vault folder
web_vault_folder: String, false, def, "web-vault/".to_string();
},
ws {
/// Enable websocket notifications
websocket_enabled: bool, false, def, false;
/// Websocket address
websocket_address: String, false, def, "0.0.0.0".to_string();
/// Websocket port
websocket_port: u16, false, def, 3012;
},
/// General settings
settings {
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://'
/// and port, if it's different than the default. Some server functions don't work correctly without this value
domain: String, true, def, "http://localhost".to_string();
/// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used.
domain_set: bool, false, def, false;
/// Enable web vault
web_vault_enabled: bool, false, def, true;
/// 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,
/// otherwise it will delete them and they won't be downloaded again.
disable_icon_download: bool, true, def, false;
/// Allow new signups |> Controls if new users can register. Note that while this is disabled, users could still be invited
signups_allowed: bool, true, def, true;
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are disabled
invitations_allowed: bool, true, def, true;
/// Password iterations |> Number of server-side passwords hashing iterations.
/// The changes only apply when a user changes their password. Not recommended to lower the value
password_iterations: i32, true, def, 100_000;
/// Show password hints |> Controls if the password hint should be shown directly in the web page.
/// Otherwise, if email is disabled, there is no way to see the password hint
show_password_hint: bool, true, def, true;
/// Admin page token |> The token used to authenticate in this very same page. Changing it here won't deauthorize the current session
admin_token: Pass, true, option;
},
/// Advanced settings
advanced {
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be redownloaded
icon_cache_ttl: u64, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u64, true, def, 259_200;
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
icon_download_timeout: u64, true, def, 10;
/// Icon blacklist Regex |> Any domains or IPs that match this regex won't be fetched by the icon service.
/// Useful to hide other servers in the local network. Check the WIKI for more details
icon_blacklist_regex: String, true, option;
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
/// Note that the checkbox would still be present, but ignored.
disable_2fa_remember: bool, true, def, false;
/// Reload templates (Dev) |> When this is set to true, the templates get reloaded with every request.
/// ONLY use this during development, as it can slow down the server
reload_templates: bool, true, def, false;
/// Log routes at launch (Dev)
log_mounts: bool, true, def, false;
/// Enable extended logging
extended_logging: bool, false, def, true;
/// Log file path
log_file: String, false, option;
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using bitwarden_rs on some exotic filesystems,
/// 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;
/// Disable Admin Token (Know the risks!) |> Disables the Admin Token for the admin page so you may use your own auth in-front
disable_admin_token: bool, true, def, false;
},
/// Yubikey settings
yubico: _enable_yubico {
/// Enabled
_enable_yubico: bool, true, def, true;
/// Client ID
yubico_client_id: String, true, option;
/// Secret Key
yubico_secret_key: Pass, true, option;
/// Server
yubico_server: String, true, option;
},
/// SMTP Email Settings
smtp: _enable_smtp {
/// Enabled
_enable_smtp: bool, true, def, true;
/// Host
smtp_host: String, true, option;
/// Enable SSL
smtp_ssl: bool, true, def, true;
/// Use explicit TLS |> Enabling this would force the use of an explicit TLS connection, instead of upgrading an insecure one with STARTTLS
smtp_explicit_tls: bool, true, def, false;
/// Port
smtp_port: u16, true, auto, |c| if c.smtp_explicit_tls {465} else if c.smtp_ssl {587} else {25};
/// From Address
smtp_from: String, true, def, String::new();
/// From Name
smtp_from_name: String, true, def, "Bitwarden_RS".to_string();
/// Username
smtp_username: String, true, option;
/// Password
smtp_password: Pass, true, option;
},
}
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
if let Some(ref token) = cfg.admin_token {
if token.trim().is_empty() {
err!("`ADMIN_TOKEN` is enabled but has an empty value. To enable the admin page without token, use `DISABLE_ADMIN_TOKEN`")
}
}
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` need to be set for Yubikey OTP support")
}
if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() {
err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support")
}
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")
}
Ok(())
}
impl Config {
pub fn load() -> Result<Self, Error> {
// Loading from env and file
let _env = ConfigBuilder::from_env();
let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default();
// Create merged config, config file overwrites env
let builder = _env.merge(&_usr);
// Fill any missing with defaults
let config = builder.build();
validate_config(&config)?;
Ok(Config {
inner: RwLock::new(Inner {
templates: load_templates(&config.templates_folder),
config,
_env,
_usr,
}),
})
}
pub fn update_config(&self, other: ConfigBuilder) -> Result<(), Error> {
// Remove default values
//let builder = other.remove(&self.inner.read().unwrap()._env);
// TODO: Remove values that are defaults, above only checks those set by env and not the defaults
let builder = other;
// Serialize now before we consume the builder
let config_str = serde_json::to_string_pretty(&builder)?;
// Prepare the combined config
let config = {
let env = &self.inner.read().unwrap()._env;
env.merge(&builder).build()
};
validate_config(&config)?;
// Save both the user and the combined config
{
let mut writer = self.inner.write().unwrap();
writer.config = config;
writer._usr = builder;
}
//Save to file
use std::{fs::File, io::Write};
let mut file = File::create(&*CONFIG_FILE)?;
file.write_all(config_str.as_bytes())?;
Ok(())
}
pub fn delete_user_config(&self) -> Result<(), Error> {
crate::util::delete_file(&CONFIG_FILE)?;
// Empty user config
let usr = ConfigBuilder::default();
// Config now is env + defaults
let config = {
let env = &self.inner.read().unwrap()._env;
env.build()
};
// Save configs
{
let mut writer = self.inner.write().unwrap();
writer.config = config;
writer._usr = usr;
}
Ok(())
}
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())
}
pub fn public_rsa_key(&self) -> String {
format!("{}.pub.der", CONFIG.rsa_key_filename())
}
pub fn mail_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config;
inner._enable_smtp && inner.smtp_host.is_some()
}
pub fn yubico_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config;
inner._enable_yubico && inner.yubico_client_id.is_some() && inner.yubico_secret_key.is_some()
}
pub fn render_template<T: serde::ser::Serialize>(
&self,
name: &str,
data: &T,
) -> Result<String, crate::error::Error> {
if CONFIG.reload_templates() {
warn!("RELOADING TEMPLATES");
let hb = load_templates(CONFIG.templates_folder().as_ref());
hb.render(name, data).map_err(Into::into)
} else {
let hb = &CONFIG.inner.read().unwrap().templates;
hb.render(name, data).map_err(Into::into)
}
}
}
use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError, Renderable,
};
fn load_templates(path: &str) -> Handlebars {
let mut hb = Handlebars::new();
// Error on missing params
hb.set_strict_mode(true);
// Register helpers
hb.register_helper("case", Box::new(CaseHelper));
hb.register_helper("jsesc", Box::new(JsEscapeHelper));
macro_rules! reg {
($name:expr) => {{
let template = include_str!(concat!("static/templates/", $name, ".hbs"));
hb.register_template_string($name, template).unwrap();
}};
($name:expr, $ext:expr) => {{
reg!($name);
reg!(concat!($name, $ext));
}};
}
// First register default templates here
reg!("email/invite_accepted", ".html");
reg!("email/invite_confirmed", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/send_org_invite", ".html");
reg!("admin/base");
reg!("admin/login");
reg!("admin/page");
// And then load user templates to overwrite the defaults
// Use .hbs extension for the files
// Templates get registered with their relative name
hb.register_templates_directory(".hbs", path).unwrap();
hb
}
pub struct CaseHelper;
impl HelperDef for CaseHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'reg, 'rc>,
r: &'reg Handlebars,
ctx: &Context,
rc: &mut RenderContext<'reg>,
out: &mut Output,
) -> HelperResult {
let param = h
.param(0)
.ok_or_else(|| RenderError::new("Param not found for helper \"case\""))?;
let value = param.value().clone();
if h.params().iter().skip(1).any(|x| x.value() == &value) {
h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or(Ok(()))
} else {
Ok(())
}
}
}
pub struct JsEscapeHelper;
impl HelperDef for JsEscapeHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'reg, 'rc>,
_: &'reg Handlebars,
_: &Context,
_: &mut RenderContext<'reg>,
out: &mut Output,
) -> HelperResult {
let param = h
.param(0)
.ok_or_else(|| RenderError::new("Param not found for helper \"js_escape\""))?;
let value = param
.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 quoted_value = format!("&quot;{}&quot;", escaped_value);
out.write(&quoted_value)?;
Ok(())
}
}

View File

@@ -36,3 +36,12 @@ pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
array
}
//
// Constant time compare
//
pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
use ring::constant_time::verify_slices_are_equal;
verify_slices_are_equal(a.as_ref(), b.as_ref()).is_ok()
}

View File

@@ -25,13 +25,13 @@ pub mod schema;
/// Initializes a database pool.
pub fn init_pool() -> Pool {
let manager = ConnectionManager::new(&*CONFIG.database_url);
let manager = ConnectionManager::new(CONFIG.database_url());
r2d2::Pool::builder().build(manager).expect("Failed to create pool")
}
pub fn get_connection() -> Result<Connection, ConnectionError> {
Connection::establish(&CONFIG.database_url)
Connection::establish(&CONFIG.database_url())
}
/// Attempts to retrieve a single connection from the managed database pool. If

View File

@@ -28,7 +28,7 @@ impl Attachment {
}
pub fn get_file_path(&self) -> String {
format!("{}/{}/{}", CONFIG.attachments_folder, self.cipher_uuid, self.id)
format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id)
}
pub fn to_json(&self, host: &str) -> Value {
@@ -85,6 +85,8 @@ impl Attachment {
}
pub fn find_by_id(id: &str, conn: &DbConn) -> Option<Self> {
let id = id.to_lowercase();
attachments::table
.filter(attachments::id.eq(id))
.first::<Self>(&**conn)

View File

@@ -196,40 +196,28 @@ impl Cipher {
}
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult {
match self.get_folder_uuid(&user_uuid, &conn) {
None => {
match folder_uuid {
Some(new_folder) => {
self.update_users_revision(conn);
let folder_cipher = FolderCipher::new(&new_folder, &self.uuid);
folder_cipher.save(&conn)
}
None => Ok(()), //nothing to do
}
}
Some(current_folder) => {
match folder_uuid {
Some(new_folder) => {
if current_folder == new_folder {
Ok(()) //nothing to do
} else {
self.update_users_revision(conn);
if let Some(current_folder) =
FolderCipher::find_by_folder_and_cipher(&current_folder, &self.uuid, &conn)
{
current_folder.delete(&conn)?;
}
FolderCipher::new(&new_folder, &self.uuid).save(&conn)
}
}
None => {
self.update_users_revision(conn);
match FolderCipher::find_by_folder_and_cipher(&current_folder, &self.uuid, &conn) {
Some(current_folder) => current_folder.delete(&conn),
User::update_uuid_revision(user_uuid, &conn);
match (self.get_folder_uuid(&user_uuid, &conn), folder_uuid) {
// No changes
(None, None) => Ok(()),
(Some(ref old), Some(ref new)) if old == new => Ok(()),
// Add to folder
(None, Some(new)) => FolderCipher::new(&new, &self.uuid).save(&conn),
// Remove from folder
(Some(old), None) => match FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, &conn) {
Some(old) => old.delete(&conn),
None => err!("Couldn't move from previous folder"),
},
// Move to another folder
(Some(old), Some(new)) => {
if let Some(old) = FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, &conn) {
old.delete(&conn)?;
}
}
}
FolderCipher::new(&new, &self.uuid).save(&conn)
}
}
}

View File

@@ -43,21 +43,17 @@ use crate::error::MapResult;
/// Database methods
impl Collection {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Update affected users revision
UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
pub fn save(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
diesel::replace_into(collections::table)
.values(&*self)
.values(self)
.execute(&**conn)
.map_res("Error saving collection")
}
pub fn delete(self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
CollectionCipher::delete_all_by_collection(&self.uuid, &conn)?;
CollectionUser::delete_all_by_collection(&self.uuid, &conn)?;
@@ -73,6 +69,14 @@ impl Collection {
Ok(())
}
pub fn update_users_revision(&self, conn: &DbConn) {
UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
collections::table
.filter(collections::uuid.eq(uuid))
@@ -241,7 +245,9 @@ impl CollectionUser {
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult {
CollectionUser::find_by_collection(&collection_uuid, conn)
.iter()
.for_each(|collection| User::update_uuid_revision(&collection.user_uuid, conn));
.for_each(|collection| {
User::update_uuid_revision(&collection.user_uuid, conn);
});
diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid)))
.execute(&**conn)
@@ -272,6 +278,7 @@ pub struct CollectionCipher {
/// Database methods
impl CollectionCipher {
pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn);
diesel::replace_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
@@ -282,6 +289,7 @@ impl CollectionCipher {
}
pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn);
diesel::delete(
ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
@@ -302,4 +310,10 @@ impl CollectionCipher {
.execute(&**conn)
.map_res("Error removing ciphers from collection")
}
pub fn update_users_revision(collection_uuid: &str, conn: &DbConn) {
if let Some(collection) = Collection::find_by_uuid(collection_uuid, conn) {
collection.update_users_revision(conn);
}
}
}

View File

@@ -77,11 +77,11 @@ impl Device {
// Create the JWT claims struct, to send to the client
use crate::auth::{encode_jwt, JWTClaims, DEFAULT_VALIDITY, JWT_ISSUER};
let claims = JWTClaims {
use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
let claims = LoginJWTClaims {
nbf: time_now.timestamp(),
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
iss: JWT_ISSUER.to_string(),
iss: JWT_LOGIN_ISSUER.to_string(),
sub: user.uuid.to_string(),
premium: true,

View File

@@ -213,7 +213,7 @@ use crate::error::MapResult;
/// Database methods
impl Organization {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
UserOrganization::find_by_org(&self.uuid, conn)
.iter()
.for_each(|user_org| {
@@ -221,7 +221,7 @@ impl Organization {
});
diesel::replace_into(organizations::table)
.values(&*self)
.values(self)
.execute(&**conn)
.map_res("Error saving organization")
}
@@ -292,18 +292,10 @@ impl UserOrganization {
})
}
pub fn to_json_collection_user_details(&self, read_only: bool, conn: &DbConn) -> Value {
let user = User::find_by_uuid(&self.user_uuid, conn).unwrap();
pub fn to_json_collection_user_details(&self, read_only: bool) -> Value {
json!({
"OrganizationUserId": self.uuid,
"AccessAll": self.access_all,
"Name": user.name,
"Email": user.email,
"Type": self.type_,
"Status": self.status,
"ReadOnly": read_only,
"Object": "collectionUser",
"Id": self.uuid,
"ReadOnly": read_only
})
}
@@ -331,11 +323,11 @@ impl UserOrganization {
})
}
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
diesel::replace_into(users_organizations::table)
.values(&*self)
.values(self)
.execute(&**conn)
.map_res("Error adding user to organization")
}

View File

@@ -15,7 +15,7 @@ pub struct TwoFactor {
}
#[allow(dead_code)]
#[derive(FromPrimitive, ToPrimitive)]
#[derive(FromPrimitive)]
pub enum TwoFactorType {
Authenticator = 0,
Email = 1,
@@ -42,21 +42,6 @@ impl TwoFactor {
}
}
pub fn check_totp_code(&self, totp_code: u64) -> bool {
let totp_secret = self.data.as_bytes();
use data_encoding::BASE32;
use oath::{totp_raw_now, HashType};
let decoded_secret = match BASE32.decode(totp_secret) {
Ok(s) => s,
Err(_) => return false,
};
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
generated == totp_code
}
pub fn to_json(&self) -> Value {
json!({
"Enabled": self.enabled,
@@ -100,6 +85,7 @@ impl TwoFactor {
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::type_.lt(1000)) // Filter implementation types
.load::<Self>(&**conn)
.expect("Error loading twofactor")
}

View File

@@ -56,7 +56,7 @@ impl User {
password_hash: Vec::new(),
salt: crypto::get_random_64(),
password_iterations: CONFIG.password_iterations,
password_iterations: CONFIG.password_iterations(),
security_stamp: crate::util::get_uuid(),
@@ -86,7 +86,7 @@ impl User {
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
if let Some(ref totp_recover) = self.totp_recover {
recovery_code == totp_recover.to_lowercase()
crate::crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
} else {
false
}
@@ -120,6 +120,7 @@ impl User {
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty();
json!({
"_Enabled": !self.password_hash.is_empty(),
"Id": self.uuid,
"Name": self.name,
"Email": self.email,
@@ -137,6 +138,10 @@ impl User {
}
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("User email can't be empty")
}
self.updated_at = Utc::now().naive_utc();
diesel::replace_into(users::table) // Insert or update
@@ -168,18 +173,40 @@ impl User {
}
pub fn update_uuid_revision(uuid: &str, conn: &DbConn) {
if let Some(mut user) = User::find_by_uuid(&uuid, conn) {
if user.update_revision(conn).is_err() {
warn!("Failed to update revision for {}", user.email);
};
};
if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn) {
warn!("Failed to update revision for {}: {:#?}", uuid, e);
}
}
pub fn update_all_revisions(conn: &DbConn) -> EmptyResult {
let updated_at = Utc::now().naive_utc();
crate::util::retry(
|| {
diesel::update(users::table)
.set(users::updated_at.eq(updated_at))
.execute(&**conn)
},
10,
)
.map_res("Error updating revision date for all users")
}
pub fn update_revision(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
diesel::update(users::table.filter(users::uuid.eq(&self.uuid)))
.set(users::updated_at.eq(&self.updated_at))
Self::_update_revision(&self.uuid, &self.updated_at, conn)
}
fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
crate::util::retry(
|| {
diesel::update(users::table.filter(users::uuid.eq(uuid)))
.set(users::updated_at.eq(date))
.execute(&**conn)
},
10,
)
.map_res("Error updating user revision")
}
@@ -212,9 +239,13 @@ impl Invitation {
Self { email }
}
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("Invitation email can't be empty")
}
diesel::replace_into(invitations::table)
.values(&*self)
.values(self)
.execute(&**conn)
.map_res("Error saving invitation")
}
@@ -234,7 +265,7 @@ impl Invitation {
}
pub fn take(mail: &str, conn: &DbConn) -> bool {
CONFIG.invitations_allowed
CONFIG.invitations_allowed()
&& match Self::find_by_mail(mail, &conn) {
Some(invitation) => invitation.delete(&conn).is_ok(),
None => false,

View File

@@ -4,17 +4,19 @@
use std::error::Error as StdError;
macro_rules! make_error {
( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)* ) => {
( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => {
const BAD_REQUEST: u16 = 400;
#[derive(Display)]
enum ErrorKind { $($name( $ty )),+ }
pub struct Error { message: String, error: ErrorKind }
pub enum ErrorKind { $($name( $ty )),+ }
pub struct Error { message: String, error: ErrorKind, error_code: u16 }
$(impl From<$ty> for Error {
fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) }
})+
$(impl<S: Into<String>> From<(S, $ty)> for Error {
fn from(val: (S, $ty)) -> Self {
Error { message: val.0.into(), error: ErrorKind::$name(val.1) }
Error { message: val.0.into(), error: ErrorKind::$name(val.1), error_code: BAD_REQUEST }
}
})+
impl StdError for Error {
@@ -32,11 +34,19 @@ macro_rules! make_error {
};
}
use diesel::result::Error as DieselError;
use jsonwebtoken::errors::Error as JwtError;
use serde_json::{Error as SerError, Value};
use std::io::Error as IOError;
use diesel::result::Error as DieselErr;
use handlebars::RenderError as HbErr;
use jsonwebtoken::errors::Error as JWTErr;
use regex::Error as RegexErr;
use reqwest::Error as ReqErr;
use serde_json::{Error as SerdeErr, Value};
use std::io::Error as IOErr;
use std::time::SystemTimeError as TimeErr;
use u2f::u2ferror::U2fError as U2fErr;
use yubico::yubicoerror::YubicoError as YubiErr;
#[derive(Display, Serialize)]
pub struct Empty {}
// Error struct
// Contains a String error message, meant for the user and an enum variant, with an error of different types.
@@ -44,16 +54,23 @@ use u2f::u2ferror::U2fError as U2fErr;
// After the variant itself, there are two expressions. The first one indicates whether the error contains a source error (that we pretty print).
// The second one contains the function used to obtain the response sent to the client
make_error! {
// Just an empty error
EmptyError(Empty): _no_source, _serialize,
// Used to represent err! calls
SimpleError(String): _no_source, _api_error,
// Used for special return values, like 2FA errors
JsonError(Value): _no_source, _serialize,
DbError(DieselError): _has_source, _api_error,
DbError(DieselErr): _has_source, _api_error,
U2fError(U2fErr): _has_source, _api_error,
SerdeError(SerError): _has_source, _api_error,
JWTError(JwtError): _has_source, _api_error,
IoErrror(IOError): _has_source, _api_error,
SerdeError(SerdeErr): _has_source, _api_error,
JWTError(JWTErr): _has_source, _api_error,
TemplError(HbErr): _has_source, _api_error,
//WsError(ws::Error): _has_source, _api_error,
IOError(IOErr): _has_source, _api_error,
TimeError(TimeErr): _has_source, _api_error,
ReqError(ReqErr): _has_source, _api_error,
RegexError(RegexErr): _has_source, _api_error,
YubiError(YubiErr): _has_source, _api_error,
}
impl std::fmt::Debug for Error {
@@ -70,10 +87,19 @@ impl Error {
(usr_msg, log_msg.into()).into()
}
pub fn empty() -> Self {
Empty {}.into()
}
pub fn with_msg<M: Into<String>>(mut self, msg: M) -> Self {
self.message = msg.into();
self
}
pub fn with_code(mut self, code: u16) -> Self {
self.error_code = code;
self
}
}
pub trait MapResult<S> {
@@ -132,8 +158,10 @@ impl<'r> Responder<'r> for Error {
let usr_msg = format!("{}", self);
error!("{:#?}", self);
let code = Status::from_code(self.error_code).unwrap_or(Status::BadRequest);
Response::build()
.status(Status::BadRequest)
.status(code)
.header(ContentType::JSON)
.sized_body(Cursor::new(usr_msg))
.ok()

View File

@@ -4,27 +4,34 @@ use lettre::{ClientSecurity, ClientTlsParameters, SmtpClient, SmtpTransport, Tra
use lettre_email::EmailBuilder;
use native_tls::{Protocol, TlsConnector};
use crate::MailConfig;
use crate::CONFIG;
use crate::auth::{generate_invite_claims, encode_jwt};
use crate::api::EmptyResult;
use crate::auth::{encode_jwt, generate_invite_claims};
use crate::error::Error;
use crate::CONFIG;
fn mailer(config: &MailConfig) -> SmtpTransport {
let client_security = if config.smtp_ssl {
fn mailer() -> SmtpTransport {
let host = CONFIG.smtp_host().unwrap();
let client_security = if CONFIG.smtp_ssl() {
let tls = TlsConnector::builder()
.min_protocol_version(Some(Protocol::Tlsv11))
.build()
.unwrap();
ClientSecurity::Required(ClientTlsParameters::new(config.smtp_host.clone(), tls))
let params = ClientTlsParameters::new(host.clone(), tls);
if CONFIG.smtp_explicit_tls() {
ClientSecurity::Wrapper(params)
} else {
ClientSecurity::Required(params)
}
} else {
ClientSecurity::None
};
let smtp_client = SmtpClient::new((config.smtp_host.as_str(), config.smtp_port), client_security).unwrap();
let smtp_client = SmtpClient::new((host.as_str(), CONFIG.smtp_port()), client_security).unwrap();
let smtp_client = match (&config.smtp_username, &config.smtp_password) {
let smtp_client = match (&CONFIG.smtp_username(), &CONFIG.smtp_password()) {
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user.clone(), pass.clone())),
_ => smtp_client,
};
@@ -35,25 +42,39 @@ fn mailer(config: &MailConfig) -> SmtpTransport {
.transport()
}
pub fn send_password_hint(address: &str, hint: Option<String>, config: &MailConfig) -> EmptyResult {
let (subject, body) = if let Some(hint) = hint {
(
"Your master password hint",
format!(
"You (or someone) recently requested your master password hint.\n\n\
Your hint is: \"{}\"\n\n\
If you did not request your master password hint you can safely ignore this email.\n",
hint
),
)
} else {
(
"Sorry, you have no password hint...",
"Sorry, you have not specified any password hint...\n".into(),
)
fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> {
let (subject_html, body_html) = get_template(&format!("{}.html", template_name), &data)?;
let (_subject_text, body_text) = get_template(template_name, &data)?;
Ok((subject_html, body_html, body_text))
}
fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String, String), Error> {
let text = CONFIG.render_template(template_name, data)?;
let mut text_split = text.split("<!---------------->");
let subject = match text_split.next() {
Some(s) => s.trim().to_string(),
None => err!("Template doesn't contain subject"),
};
send_email(&address, &subject, &body, &config)
let body = match text_split.next() {
Some(s) => s.trim().to_string(),
None => err!("Template doesn't contain body"),
};
Ok((subject, body))
}
pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
let template_name = if hint.is_some() {
"email/pw_hint_some"
} else {
"email/pw_hint_none"
};
let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?;
send_email(&address, &subject, &body_html, &body_text)
}
pub fn send_invite(
@@ -63,7 +84,6 @@ pub fn send_invite(
org_user_id: Option<String>,
org_name: &str,
invited_by_email: Option<String>,
config: &MailConfig,
) -> EmptyResult {
let claims = generate_invite_claims(
uuid.to_string(),
@@ -73,67 +93,64 @@ pub fn send_invite(
invited_by_email.clone(),
);
let invite_token = encode_jwt(&claims);
let (subject, body) = {
(format!("Join {}", &org_name),
format!(
"<html>
<p>You have been invited to join the <b>{}</b> organization.<br><br>
<a href=\"{}/#/accept-organization/?organizationId={}&organizationUserId={}&email={}&organizationName={}&token={}\">
Click here to join</a></p>
<p>If you do not wish to join this organization, you can safely ignore this email.</p>
</html>",
org_name, CONFIG.domain, org_id.unwrap_or("_".to_string()), org_user_id.unwrap_or("_".to_string()), address, org_name, invite_token
))
};
send_email(&address, &subject, &body, &config)
let (subject, body_html, body_text) = get_text(
"email/send_org_invite",
json!({
"url": CONFIG.domain(),
"org_id": org_id.unwrap_or_else(|| "_".to_string()),
"org_user_id": org_user_id.unwrap_or_else(|| "_".to_string()),
"email": address,
"org_name": org_name,
"token": invite_token,
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
}
pub fn send_invite_accepted(
new_user_email: &str,
address: &str,
org_name: &str,
config: &MailConfig,
) -> EmptyResult {
let (subject, body) = {
("Invitation accepted",
format!(
"<html>
<p>Your invitation for <b>{}</b> to join <b>{}</b> was accepted. Please <a href=\"{}\">log in</a> to the bitwarden_rs server and confirm them from the organization management page.</p>
</html>", new_user_email, org_name, CONFIG.domain))
};
pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/invite_accepted",
json!({
"url": CONFIG.domain(),
"email": new_user_email,
"org_name": org_name,
}),
)?;
send_email(&address, &subject, &body, &config)
send_email(&address, &subject, &body_html, &body_text)
}
pub fn send_invite_confirmed(
address: &str,
org_name: &str,
config: &MailConfig,
) -> EmptyResult {
let (subject, body) = {
(format!("Invitation to {} confirmed", org_name),
format!(
"<html>
<p>Your invitation to join <b>{}</b> was confirmed. It will now appear under the Organizations the next time you <a href=\"{}\">log in</a> to the web vault.</p>
</html>", org_name, CONFIG.domain))
};
pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/invite_confirmed",
json!({
"url": CONFIG.domain(),
"org_name": org_name,
}),
)?;
send_email(&address, &subject, &body, &config)
send_email(&address, &subject, &body_html, &body_text)
}
fn send_email(address: &str, subject: &str, body: &str, config: &MailConfig) -> EmptyResult {
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
let email = EmailBuilder::new()
.to(address)
.from((config.smtp_from.clone(), "Bitwarden-rs"))
.from((CONFIG.smtp_from().as_str(), CONFIG.smtp_from_name().as_str()))
.subject(subject)
.header(("Content-Type", "text/html"))
.body(body)
.alternative(body_html, body_text)
.build()
.map_err(|e| Error::new("Error building email", e.to_string()))?;
mailer(config)
let mut transport = mailer();
let result = transport
.send(email.into())
.map_err(|e| Error::new("Error sending email", e.to_string()))
.and(Ok(()))
.and(Ok(()));
// Explicitly close the connection, in case of error
transport.close();
result
}

View File

@@ -1,6 +1,5 @@
#![feature(proc_macro_hygiene, decl_macro, vec_remove_item, try_trait)]
#![recursion_limit = "128"]
#![allow(proc_macro_derive_resolution_fallback)] // TODO: Remove this when diesel update fixes warnings
#![recursion_limit = "256"]
#[macro_use]
extern crate rocket;
@@ -21,7 +20,6 @@ extern crate derive_more;
#[macro_use]
extern crate num_derive;
use rocket::Rocket;
use std::{
path::Path,
process::{exit, Command},
@@ -31,42 +29,19 @@ use std::{
mod error;
mod api;
mod auth;
mod config;
mod crypto;
mod db;
mod mail;
mod util;
fn init_rocket() -> Rocket {
rocket::ignite()
.mount("/", api::web_routes())
.mount("/api", api::core_routes())
.mount("/admin", api::admin_routes())
.mount("/identity", api::identity_routes())
.mount("/icons", api::icons_routes())
.mount("/notifications", api::notifications_routes())
.manage(db::init_pool())
.manage(api::start_notification_server())
.attach(util::AppHeaders())
}
// Embed the migrations from the migrations folder into the application
// This way, the program automatically migrates the database to the latest version
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
#[allow(unused_imports)]
mod migrations {
embed_migrations!();
pub fn run_migrations() {
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = crate::db::get_connection().expect("Can't conect to DB");
use std::io::stdout;
embedded_migrations::run_with_output(&connection, &mut stdout()).expect("Can't run migrations");
}
}
pub use config::CONFIG;
pub use error::{Error, MapResult};
fn main() {
if CONFIG.extended_logging {
launch_info();
if CONFIG.extended_logging() {
init_logging().ok();
}
@@ -75,7 +50,22 @@ fn main() {
check_web_vault();
migrations::run_migrations();
init_rocket().launch();
launch_rocket();
}
fn launch_info() {
println!("/--------------------------------------------------------------------\\");
println!("| Starting Bitwarden_RS |");
if let Some(version) = option_env!("GIT_VERSION") {
println!("|{:^68}|", format!("Version {}", version));
}
println!("|--------------------------------------------------------------------|");
println!("| This is an *unofficial* Bitwarden implementation, DO NOT use the |");
println!("| official channels to report bugs/features, regardless of client. |");
println!("| Report URL: https://github.com/dani-garcia/bitwarden_rs/issues/new |");
println!("\\--------------------------------------------------------------------/\n");
}
fn init_logging() -> Result<(), fern::InitError> {
@@ -91,11 +81,14 @@ fn init_logging() -> Result<(), fern::InitError> {
})
.level(log::LevelFilter::Debug)
.level_for("hyper", log::LevelFilter::Warn)
.level_for("rustls", log::LevelFilter::Warn)
.level_for("handlebars", log::LevelFilter::Warn)
.level_for("ws", log::LevelFilter::Info)
.level_for("multipart", log::LevelFilter::Info)
.level_for("html5ever", log::LevelFilter::Info)
.chain(std::io::stdout());
if let Some(log_file) = CONFIG.log_file.as_ref() {
if let Some(log_file) = CONFIG.log_file() {
logger = logger.chain(fern::log_file(log_file)?);
}
@@ -129,7 +122,8 @@ fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch {
}
fn check_db() {
let path = Path::new(&CONFIG.database_url);
let url = CONFIG.database_url();
let path = Path::new(&url);
if let Some(parent) = path.parent() {
use std::fs;
@@ -140,61 +134,50 @@ fn check_db() {
}
// Turn on WAL in SQLite
if CONFIG.enable_db_wal() {
use diesel::RunQueryDsl;
let connection = db::get_connection().expect("Can't conect to DB");
diesel::sql_query("PRAGMA journal_mode=wal")
.execute(&connection)
.expect("Failed to turn on WAL");
}
}
fn check_rsa_keys() {
// If the RSA keys don't exist, try to create them
if !util::file_exists(&CONFIG.private_rsa_key) || !util::file_exists(&CONFIG.public_rsa_key) {
if !util::file_exists(&CONFIG.private_rsa_key()) || !util::file_exists(&CONFIG.public_rsa_key()) {
info!("JWT keys don't exist, checking if OpenSSL is available...");
Command::new("openssl").arg("version").output().unwrap_or_else(|_| {
Command::new("openssl").arg("version").status().unwrap_or_else(|_| {
info!("Can't create keys because OpenSSL is not available, make sure it's installed and available on the PATH");
exit(1);
});
info!("OpenSSL detected, creating keys...");
let key = CONFIG.rsa_key_filename();
let pem = format!("{}.pem", key);
let priv_der = format!("{}.der", key);
let pub_der = format!("{}.pub.der", key);
let mut success = Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(&CONFIG.private_rsa_key_pem)
.output()
.args(&["genrsa", "-out", &pem])
.status()
.expect("Failed to create private pem file")
.status
.success();
success &= Command::new("openssl")
.arg("rsa")
.arg("-in")
.arg(&CONFIG.private_rsa_key_pem)
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(&CONFIG.private_rsa_key)
.output()
.args(&["rsa", "-in", &pem, "-outform", "DER", "-out", &priv_der])
.status()
.expect("Failed to create private der file")
.status
.success();
success &= Command::new("openssl")
.arg("rsa")
.arg("-in")
.arg(&CONFIG.private_rsa_key)
.arg("-inform")
.arg("DER")
.arg("-RSAPublicKey_out")
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(&CONFIG.public_rsa_key)
.output()
.args(&["rsa", "-in", &priv_der, "-inform", "DER"])
.args(&["-RSAPublicKey_out", "-outform", "DER", "-out", &pub_der])
.status()
.expect("Failed to create public der file")
.status
.success();
if success {
@@ -207,168 +190,62 @@ fn check_rsa_keys() {
}
fn check_web_vault() {
if !CONFIG.web_vault_enabled {
if !CONFIG.web_vault_enabled() {
return;
}
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html");
let index_path = Path::new(&CONFIG.web_vault_folder()).join("index.html");
if !index_path.exists() {
error!("Web vault is not found. Please follow the steps in the README to install it");
error!("Web vault is not found. To install it, please follow the steps in https://github.com/dani-garcia/bitwarden_rs/wiki/Building-binary#install-the-web-vault");
exit(1);
}
}
lazy_static! {
// Load the config from .env or from environment variables
static ref CONFIG: Config = Config::load();
}
// Embed the migrations from the migrations folder into the application
// This way, the program automatically migrates the database to the latest version
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
#[allow(unused_imports)]
mod migrations {
embed_migrations!();
#[derive(Debug)]
pub struct MailConfig {
smtp_host: String,
smtp_port: u16,
smtp_ssl: bool,
smtp_from: String,
smtp_username: Option<String>,
smtp_password: Option<String>,
}
pub fn run_migrations() {
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
let connection = crate::db::get_connection().expect("Can't connect to DB");
impl MailConfig {
fn load() -> Option<Self> {
use crate::util::{get_env, get_env_or};
// When SMTP_HOST is absent, we assume the user does not want to enable it.
let smtp_host = match get_env("SMTP_HOST") {
Some(host) => host,
None => return None,
};
let smtp_from = get_env("SMTP_FROM").unwrap_or_else(|| {
error!("Please specify SMTP_FROM to enable SMTP support.");
exit(1);
});
let smtp_ssl = get_env_or("SMTP_SSL", true);
let smtp_port = get_env("SMTP_PORT").unwrap_or_else(|| if smtp_ssl { 587u16 } else { 25u16 });
let smtp_username = get_env("SMTP_USERNAME");
let smtp_password = get_env("SMTP_PASSWORD").or_else(|| {
if smtp_username.as_ref().is_some() {
error!("SMTP_PASSWORD is mandatory when specifying SMTP_USERNAME.");
exit(1);
} else {
None
}
});
Some(MailConfig {
smtp_host,
smtp_port,
smtp_ssl,
smtp_from,
smtp_username,
smtp_password,
})
use std::io::stdout;
embedded_migrations::run_with_output(&connection, &mut stdout()).expect("Can't run migrations");
}
}
#[derive(Debug)]
pub struct Config {
database_url: String,
icon_cache_folder: String,
attachments_folder: String,
fn launch_rocket() {
// Create Rocket object, this stores current log level and sets it's own
let rocket = rocket::ignite();
icon_cache_ttl: u64,
icon_cache_negttl: u64,
private_rsa_key: String,
private_rsa_key_pem: String,
public_rsa_key: String,
web_vault_folder: String,
web_vault_enabled: bool,
websocket_enabled: bool,
websocket_url: String,
extended_logging: bool,
log_file: Option<String>,
local_icon_extractor: bool,
signups_allowed: bool,
invitations_allowed: bool,
admin_token: Option<String>,
password_iterations: i32,
show_password_hint: bool,
domain: String,
domain_set: bool,
yubico_cred_set: bool,
yubico_client_id: String,
yubico_secret_key: String,
yubico_server: Option<String>,
mail: Option<MailConfig>,
}
impl Config {
fn load() -> Self {
use crate::util::{get_env, get_env_or};
dotenv::dotenv().ok();
let df = get_env_or("DATA_FOLDER", "data".to_string());
let key = get_env_or("RSA_KEY_FILENAME", format!("{}/{}", &df, "rsa_key"));
let domain = get_env("DOMAIN");
let yubico_client_id = get_env("YUBICO_CLIENT_ID");
let yubico_secret_key = get_env("YUBICO_SECRET_KEY");
Config {
database_url: get_env_or("DATABASE_URL", format!("{}/{}", &df, "db.sqlite3")),
icon_cache_folder: get_env_or("ICON_CACHE_FOLDER", format!("{}/{}", &df, "icon_cache")),
attachments_folder: get_env_or("ATTACHMENTS_FOLDER", format!("{}/{}", &df, "attachments")),
// icon_cache_ttl defaults to 30 days (30 * 24 * 60 * 60 seconds)
icon_cache_ttl: get_env_or("ICON_CACHE_TTL", 2_592_000),
// icon_cache_negttl defaults to 3 days (3 * 24 * 60 * 60 seconds)
icon_cache_negttl: get_env_or("ICON_CACHE_NEGTTL", 259_200),
private_rsa_key: format!("{}.der", &key),
private_rsa_key_pem: format!("{}.pem", &key),
public_rsa_key: format!("{}.pub.der", &key),
web_vault_folder: get_env_or("WEB_VAULT_FOLDER", "web-vault/".into()),
web_vault_enabled: get_env_or("WEB_VAULT_ENABLED", true),
websocket_enabled: get_env_or("WEBSOCKET_ENABLED", false),
websocket_url: format!(
"{}:{}",
get_env_or("WEBSOCKET_ADDRESS", "0.0.0.0".to_string()),
get_env_or("WEBSOCKET_PORT", 3012)
),
extended_logging: get_env_or("EXTENDED_LOGGING", true),
log_file: get_env("LOG_FILE"),
local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false),
signups_allowed: get_env_or("SIGNUPS_ALLOWED", true),
admin_token: get_env("ADMIN_TOKEN"),
invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true),
password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000),
show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true),
domain_set: domain.is_some(),
domain: domain.unwrap_or("http://localhost".into()),
yubico_cred_set: yubico_client_id.is_some() && yubico_secret_key.is_some(),
yubico_client_id: yubico_client_id.unwrap_or("00000".into()),
yubico_secret_key: yubico_secret_key.unwrap_or("AAAAAAA".into()),
yubico_server: get_env("YUBICO_SERVER"),
mail: MailConfig::load(),
// If we aren't logging the mounts, we force the logging level down
if !CONFIG.log_mounts() {
log::set_max_level(log::LevelFilter::Warn);
}
let rocket = rocket
.mount("/", api::web_routes())
.mount("/api", api::core_routes())
.mount("/admin", api::admin_routes())
.mount("/identity", api::identity_routes())
.mount("/icons", api::icons_routes())
.mount("/notifications", api::notifications_routes());
// Force the level up for the fairings, managed state and lauch
if !CONFIG.log_mounts() {
log::set_max_level(log::LevelFilter::max());
}
let rocket = rocket
.manage(db::init_pool())
.manage(api::start_notification_server())
.attach(util::AppHeaders());
// Launch and print error if there is one
// The launch will restore the original logging level
error!("Launch error {:#?}", rocket.launch());
}

View File

@@ -1,195 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Bitwarden_rs Admin Panel</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=" crossorigin="anonymous" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.10.0/js/md5.js" integrity="sha256-tCQ/BldMlN2vWe5gAiNoNb5svoOgVUhlUgv7UjONKKQ="
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/identicon.js/2.3.3/identicon.min.js" integrity="sha256-nYoL3nK/HA1e1pJvLwNPnpKuKG9q89VFX862r5aohmA="
crossorigin="anonymous"></script>
<style>
body { padding-top: 70px; }
img { width: 48px; height: 48px; }
</style>
<script>
let key = null;
function identicon(email) {
const data = new Identicon(md5(email), {
size: 48, format: 'svg'
}).toString();
return "data:image/svg+xml;base64," + data;
}
function setVis(elem, vis) {
if (vis) { $(elem).removeClass('d-none'); }
else { $(elem).addClass('d-none'); }
}
function updateVis() {
setVis("#no-key-form", !key);
setVis("#users-block", key);
setVis("#invite-form-block", key);
}
function setKey() {
key = $('#key').val() || window.location.hash.slice(1);
updateVis();
if (key) { loadUsers(); }
return false;
}
function resetKey() {
key = null;
updateVis();
}
function fillRow(data) {
for (i in data) {
const user = data[i];
const row = $("#tmp-row").clone();
row.attr("id", "user-row:" + user.Id);
row.find(".tmp-name").text(user.Name);
row.find(".tmp-mail").text(user.Email);
row.find(".tmp-icon").attr("src", identicon(user.Email))
row.find(".tmp-del").on("click", function (e) {
var name = prompt("To delete user '" + user.Name + "', please type the name below")
if (name) {
if (name == user.Name) {
deleteUser(user.Id);
} else {
alert("Wrong name, please try again")
}
}
return false;
});
row.appendTo("#users-list");
setVis(row, true);
}
}
function _headers() { return { "Authorization": "Bearer " + key }; }
function loadUsers() {
$("#users-list").empty();
$.get({ url: "/admin/users", headers: _headers() })
.done(fillRow).fail(resetKey);
return false;
}
function _post(url, successMsg, errMsg, resetOnErr, data) {
$.post({ url: url, headers: _headers(), data: data })
.done(function () {
alert(successMsg);
loadUsers();
}).fail(function (e) {
const r = e.responseJSON;
const msg = r ? r.ErrorModel.Message : "Unknown error";
alert(errMsg + ": " + msg);
if (resetOnErr) { resetKey(); }
});
}
function deleteUser(id) {
_post("/admin/users/" + id + "/delete",
"User deleted correctly",
"Error deleting user", true);
}
function inviteUser() {
inv = $("#email-invite");
data = JSON.stringify({ "Email": inv.val() });
inv.val("");
_post("/admin/invite/", "User invited correctly",
"Error inviting user", false, data);
return false;
}
$(window).on('load', function () {
setKey();
$("#key-form").submit(setKey);
$("#reload-btn").click(loadUsers);
$("#invite-form").submit(inviteUser);
});
</script>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top shadow">
<a class="navbar-brand" href="#">Bitwarden_rs</a>
<div class="navbar-collapse">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="/admin">Admin Panel</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/">Vault</a>
</li>
</ul>
</div>
</nav>
<main class="container">
<div id="no-key-form" class="d-none align-items-center p-3 mb-3 text-white-50 bg-danger rounded shadow">
<div>
<h6 class="mb-0 text-white">Authentication key needed to continue</h6>
<small>Please provide it below:</small>
<form class="form-inline" id="key-form">
<input type="password" class="form-control w-50 mr-2" id="key" placeholder="Enter admin key">
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
<div id="users-block" class="d-none my-3 p-3 bg-white rounded shadow">
<h6 class="border-bottom pb-2 mb-0">Registered Users</h6>
<div id="users-list"></div>
<small class="d-block text-right mt-3">
<a id="reload-btn" href="#">Reload users</a>
</small>
</div>
<div id="invite-form-block" class="d-none align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow">
<div>
<h6 class="mb-0 text-white">Invite User</h6>
<small>Email:</small>
<form class="form-inline" id="invite-form">
<input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email">
<button type="submit" class="btn btn-primary">Invite</button>
</form>
</div>
</div>
<div id="tmp-row" class="d-none media pt-3">
<img class="mr-2 rounded tmp-icon">
<div class="media-body pb-3 mb-0 small border-bottom">
<div class="d-flex justify-content-between">
<strong class="tmp-name">Full Name</strong>
<a class="tmp-del mr-3" href="#">Delete User</a>
</div>
<span class="d-block tmp-mail">Email</span>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Bitwarden_rs Admin Panel</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha256-YLGeXaapI0/5IgZopewRJcFXomhRMlYYjugPLSyNjTY=" crossorigin="anonymous" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.10.0/js/md5.min.js"
integrity="sha256-J9IhvkIJb0diRVJOyu+Ndtg41RibFkF8eaA60jdjtB8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/identicon.js/2.3.3/identicon.min.js"
integrity="sha256-nYoL3nK/HA1e1pJvLwNPnpKuKG9q89VFX862r5aohmA=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.bundle.min.js"
integrity="sha256-fzFFyH01cBVPYzl16KT40wqjhgPtq6FFUB6ckN2+GGw=" crossorigin="anonymous"></script>
<style>
body {
padding-top: 70px;
}
@media (max-width:768px) {
body {
padding-top: 190px;
}
.container {
max-width: 100%;
}
}
img {
width: 48px;
height: 48px;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top shadow">
<a class="navbar-brand" href="#">Bitwarden_rs</a>
<div class="navbar-collapse">
<ul class="navbar-nav">
<li class="nav-item active">
<a class="nav-link" href="/admin">Admin Panel</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/">Vault</a>
</li>
</ul>
</div>
{{#if version}}
<div class="navbar-text">Version: {{version}}</div>
{{/if}}
</nav>
{{> (page_content) }}
</body>
</html>

View File

@@ -0,0 +1,21 @@
<main class="container">
{{#if error}}
<div class="align-items-center p-3 mb-3 text-white-50 bg-warning rounded shadow">
<div>
<h6 class="mb-0 text-white">{{error}}</h6>
</div>
</div>
{{/if}}
<div class="align-items-center p-3 mb-3 text-white-50 bg-danger rounded shadow">
<div>
<h6 class="mb-0 text-white">Authentication key needed to continue</h6>
<small>Please provide it below:</small>
<form class="form-inline" method="post">
<input type="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token">
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
</main>

View File

@@ -0,0 +1,310 @@
<main class="container">
<div id="users-block" class="my-3 p-3 bg-white rounded shadow">
<h6 class="border-bottom pb-2 mb-0">Registered Users</h6>
<div id="users-list">
{{#each users}}
<div class="media pt-3">
<img class="mr-2 rounded identicon" data-src="{{Email}}">
<div class="media-body pb-3 mb-0 small border-bottom">
<div class="row justify-content-between">
<div class="col">
<strong>{{Name}}</strong>
{{#if TwoFactorEnabled}}
<span class="badge badge-success ml-2">2FA</span>
{{/if}}
{{#unless _Enabled}}
<span class="badge badge-warning ml-2">Disabled</span>
{{/unless}}
<span class="d-block">{{Email}}</span>
</div>
<div class="col">
<strong> Organizations:</strong>
<span class="d-block">
{{#each Organizations}}
<span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
{{/each}}
</span>
</div>
<div style="flex: 0 0 240px;">
<a class="mr-3" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
<a class="mr-3" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a>
</div>
</div>
</div>
</div>
{{/each}}
</div>
<div class="mt-3">
<button type="button" class="btn btn-sm btn-link" onclick="updateRevisions();"
title="Force all clients to fetch new data next time they connect. Useful after restoring a backup to remove any stale data.">
Force clients to resync
</button>
<button type="button" class="btn btn-sm btn-primary float-right" onclick="reload();">Reload users</button>
</div>
</div>
<div id="invite-form-block" class="align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow">
<div>
<h6 class="mb-0 text-white">Invite User</h6>
<small>Email:</small>
<form class="form-inline" id="invite-form">
<input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email">
<button type="submit" class="btn btn-primary">Invite</button>
</form>
</div>
</div>
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
<div>
<h6 class="text-white mb-3">Configuration</h6>
<div class="small text-white mb-3">
NOTE: The settings here override the environment variables. Once saved, it's recommended to stop setting
them to avoid confusion. This does not apply to the read-only section, which can only be set through the
environment.
</div>
<form class="form accordion" id="config-form">
{{#each config}}
{{#if groupdoc}}
<div class="card bg-light mb-3">
<div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
data-target="#g_{{group}}">{{groupdoc}}</button></div>
<div id="g_{{group}}" class="card-body collapse" data-parent="#config-form">
{{#each elements}}
{{#if editable}}
<div class="form-group row" title="[{{name}}] {{doc.description}}">
{{#case type "text" "number" "password"}}
<label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
<div class="col-sm-8 input-group">
<input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}"
name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}"
{{/if}}>
{{#case type "password"}}
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button"
onclick="toggleVis('#input_{{name}}');">Show/hide</button>
</div>
{{/case}}
</div>
{{/case}}
{{#case type "checkbox"}}
<div class="col-sm-3">{{doc.name}}</div>
<div class="col-sm-8">
<div class="form-check">
<input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}"
name="{{name}}" {{#if value}} checked {{/if}}>
<label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
</div>
</div>
{{/case}}
</div>
{{/if}}
{{/each}}
</div>
</div>
{{/if}}
{{/each}}
<div class="card bg-light mb-3">
<div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
data-target="#g_readonly">Read-Only Config</button></div>
<div id="g_readonly" class="card-body collapse" data-parent="#config-form">
<div class="small mb-3">
NOTE: These options can't be modified in the editor because they would require the server
to be restarted. To modify them, you need to set the correct environment variables when
launching the server. You can check the variable names in the tooltips of each option.
</div>
{{#each config}}
{{#each elements}}
{{#unless editable}}
<div class="form-group row" title="[{{name}}] {{doc.description}}">
{{#case type "text" "number" "password"}}
<label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
<div class="col-sm-8 input-group">
<input readonly class="form-control" id="input_{{name}}" type="{{type}}"
value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
{{#case type "password"}}
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button"
onclick="toggleVis('#input_{{name}}');">Show/hide</button>
</div>
{{/case}}
</div>
{{/case}}
{{#case type "checkbox"}}
<div class="col-sm-3">{{doc.name}}</div>
<div class="col-sm-8">
<div class="form-check">
<input disabled class="form-check-input" type="checkbox" id="input_{{name}}"
{{#if value}} checked {{/if}}>
<label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
</div>
</div>
{{/case}}
</div>
{{/unless}}
{{/each}}
{{/each}}
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-danger float-right" onclick="deleteConf();">Reset defaults</button>
</form>
</div>
</div>
</main>
<style>
#config-block ::placeholder {
/* Most modern browsers support this now. */
color: orangered;
}
</style>
<script>
function reload() { window.location.reload(); }
function identicon(email) {
const data = new Identicon(md5(email), { size: 48, format: 'svg' });
return "data:image/svg+xml;base64," + data.toString();
}
function toggleVis(input_id) {
var type = $(input_id).attr("type");
if (type === "text") {
$(input_id).attr("type", "password");
} else {
$(input_id).attr("type", "text");
}
return false;
}
function _post(url, successMsg, errMsg, data) {
$.post({
url: url,
data: data,
//async: false,
contentType: "application/json",
}).done(function () {
alert(successMsg);
}).fail(function (e) {
const r = e.responseJSON;
const msg = r ? r.ErrorModel.Message : "Unknown error";
alert(errMsg + ": " + msg);
}).always(reload);
}
function deleteUser(id, mail) {
var input_mail = prompt("To delete user '" + mail + "', please type the name below")
if (input_mail != null) {
if (input_mail == mail) {
_post("/admin/users/" + id + "/delete",
"User deleted correctly",
"Error deleting user");
} else {
alert("Wrong email, please try again")
}
}
return false;
}
function deauthUser(id) {
_post("/admin/users/" + id + "/deauth",
"Sessions deauthorized correctly",
"Error deauthorizing sessions");
return false;
}
function updateRevisions() {
_post("/admin/users/update_revision",
"Success, clients will sync next time they connect",
"Error forcing clients to sync");
return false;
}
function inviteUser() {
inv = $("#email-invite");
data = JSON.stringify({ "email": inv.val() });
inv.val("");
_post("/admin/invite/", "User invited correctly",
"Error inviting user", data);
return false;
}
function getFormData() {
let data = {};
$(".conf-checkbox").each(function (i, e) {
data[e.name] = $(e).is(":checked");
});
$(".conf-number").each(function (i, e) {
data[e.name] = +e.value;
});
$(".conf-text, .conf-password").each(function (i, e) {
data[e.name] = e.value || null;
});
return data;
}
function saveConfig() {
data = JSON.stringify(getFormData());
_post("/admin/config/", "Config saved correctly",
"Error saving config", data);
return false;
}
function deleteConf() {
var input = prompt("This will remove all user configurations, and restore the defaults and the " +
"values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:");
if (input === "DELETE") {
_post("/admin/config/delete",
"Config deleted correctly",
"Error deleting config");
} else {
alert("Wrong input, please try again")
}
return false;
}
function masterCheck(check_id, inputs_query) {
function toggleEnabled(check_id, inputs_query, enabled) {
$(inputs_query).prop("disabled", !enabled)
if (!enabled)
$(inputs_query).val("");
$(check_id).prop("disabled", false);
};
function onChanged(check_id, inputs_query) {
return function _fn() { toggleEnabled(check_id, inputs_query, this.checked); };
};
toggleEnabled(check_id, inputs_query, $(check_id).is(":checked"));
$(check_id).change(onChanged(check_id, inputs_query));
}
let OrgTypes = {
"0": { "name": "Owner", "color": "orange" },
"1": { "name": "Admin", "color": "blueviolet" },
"2": { "name": "User", "color": "blue" },
"3": { "name": "Manager", "color": "green" },
};
$(window).on('load', function () {
$("#invite-form").submit(inviteUser);
$("#config-form").submit(saveConfig);
$("img.identicon").each(function (i, e) {
e.src = identicon(e.dataset.src);
});
$('[data-orgtype]').each(function (i, e) {
let orgtype = OrgTypes[e.dataset.orgtype];
e.style.backgroundColor = orgtype.color;
e.title = orgtype.name;
});
// These are formatted because otherwise the
// VSCode formatter breaks But they still work
// {{#each config}} {{#if grouptoggle}}
masterCheck("#input_{{grouptoggle}}", "#g_{{group}} input");
// {{/if}} {{/each}}
});
</script>

View File

@@ -0,0 +1,8 @@
Invitation accepted
<!---------------->
<html>
<p>
Your invitation for <b>{{email}}</b> to join <b>{{org_name}}</b> was accepted.
Please <a href="{{url}}">log in</a> to the bitwarden_rs server and confirm them from the organization management page.
</p>
</html>

View File

@@ -0,0 +1,138 @@
Invitation accepted
<!---------------->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden_rs</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
</style>
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center">
<p style="text-align: center"><strong>Bitwarden_rs</strong></p>
</td>
</tr>
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
This email is to notify you that {{email}} has accepted your invitation to join <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b>.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Please <a href="{{url}}">log in</a> to the bitwarden_rs server and confirm them from the organization management page.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
If you do not wish to confirm this user, you can also remove them from the organization on the same page.
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top">
<a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;">
<p style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;">GitHub</p>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Invitation to {{org_name}} confirmed
<!---------------->
<html>
<p>
Your invitation to join <b>{{org_name}}</b> was confirmed.
It will now appear under the Organizations the next time you <a href="{{url}}">log in</a> to the web vault.
</p>
</html>

View File

@@ -0,0 +1,134 @@
Invitation to {{org_name}} confirmed
<!---------------->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden_rs</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
</style>
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center">
<p style="text-align: center"><strong>Bitwarden_rs</strong></p>
</td>
</tr>
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
This email is to notify you that you have been confirmed as a user of <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b>.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Any collections and logins being shared with you by this organization will now appear in your Bitwarden vault. <br>
<a href="{{url}}">Log in</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top">
<a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;">
<p style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;">GitHub</p>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,3 @@
Sorry, you have no password hint...
<!---------------->
Sorry, you have not specified any password hint...

View File

@@ -0,0 +1,133 @@
Sorry, you have no password hint...
<!---------------->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden_rs</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
</style>
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center">
<p style="text-align: center"><strong>Bitwarden_rs</strong></p>
</td>
</tr>
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
You (or someone) recently requested your master password hint. Unfortunately, your account does not have a master password hint. <br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
If you did not request your master password hint you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top">
<a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;">
<p style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;">GitHub</p>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,7 @@
Your master password hint
<!---------------->
You (or someone) recently requested your master password hint.
Your hint is: "{{hint}}"
If you did not request your master password hint you can safely ignore this email.

View File

@@ -0,0 +1,139 @@
Your master password hint
<!---------------->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden_rs</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
</style>
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center">
<p style="text-align: center"><strong>Bitwarden_rs</strong></p>
</td>
</tr>
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
You (or someone) recently requested your master password hint.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Your hint is: "{{hint}}"<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Log in: <a href="{{url}}">Web Vault</a>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
If you did not request your master password hint you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top">
<a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;">
<p style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;">GitHub</p>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,12 @@
Join {{org_name}}
<!---------------->
<html>
<p>
You have been invited to join the <b>{{org_name}}</b> organization.
<br>
<br>
<a href="{{url}}/#/accept-organization/?organizationId={{org_id}}&organizationUserId={{org_user_id}}&email={{email}}&organizationName={{org_name}}&token={{token}}">
Click here to join</a>
</p>
<p>If you do not wish to join this organization, you can safely ignore this email.</p>
</html>

View File

@@ -0,0 +1,141 @@
Join {{org_name}}
<!---------------->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bitwarden_rs</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 25px; width: 100% !important;" bgcolor="#f6f6f6">
<style type="text/css">
 body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
body * {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 16px;
color: #333;
line-height: 25px;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
}
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 25px;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 600px) {
body {
padding: 0 !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.container-table {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 0 10px 0 !important;
}
.content-wrap {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
.main {
border-right: none !important;
border-left: none !important;
border-radius: 0 !important;
}
.logo {
padding-top: 10px !important;
}
.footer {
margin-top: 10px !important;
}
.indented {
padding-left: 10px;
}
}
</style>
<table class="body-wrap" cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; width: 100%;" bgcolor="#f6f6f6">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td valign="middle" class="aligncenter middle logo" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; padding: 20px 0 10px;" align="center">
<p style="text-align: center"><strong>Bitwarden_rs</strong></p>
</td>
</tr>
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="container" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;" valign="top">
<table cellpadding="0" cellspacing="0" class="container-table" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both !important; color: #333; display: block !important; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto; max-width: 600px !important; width: 600px;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="content" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; display: block; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 0; line-height: 0; margin: 0 auto; max-width: 600px; padding-bottom: 20px;" valign="top">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; margin: 0; -webkit-text-size-adjust: none; border: 1px solid #e9e9e9; border-radius: 3px;" bgcolor="white">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-wrap" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 20px; -webkit-text-size-adjust: none;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
You have been invited to join the <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{org_name}}</b> organization.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{url}}/#/accept-organization/?organizationId={{org_id}}&organizationUserId={{org_user_id}}&email={{email}}&organizationName={{org_name}}&token={{token}}"
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Join Organization Now
</a>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
If you do not wish to join this organization, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="footer" cellpadding="0" cellspacing="0" width="100%" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; width: 100%;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td class="aligncenter social-icons" align="center" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 15px 0 0 0;" valign="top">
<table cellpadding="0" cellspacing="0" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0 auto;">
<tr style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0;">
<td style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; padding: 0 10px;" valign="top">
<a href="https://github.com/dani-garcia/bitwarden_rs" target="_blank" style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; line-height: 20px; margin: 0; text-decoration: underline;">
<p style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; border: none; box-sizing: border-box; color: #333; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 25px; margin: 0; max-width: 100%;">GitHub</p>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,7 +1,8 @@
//
// Web Headers
// Web Headers and caching
//
use rocket::fairing::{Fairing, Info, Kind};
use rocket::response::{self, Responder};
use rocket::{Request, Response};
pub struct AppHeaders();
@@ -15,6 +16,7 @@ impl Fairing for AppHeaders {
}
fn on_response(&self, _req: &Request, res: &mut Response) {
res.set_raw_header("Feature-Policy", "accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; camera 'none'; encrypted-media 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; picture-in-picture 'none'; sync-xhr 'self' https://haveibeenpwned.com https://twofactorauth.org; usb 'none'; vr 'none'");
res.set_raw_header("Referrer-Policy", "same-origin");
res.set_raw_header("X-Frame-Options", "SAMEORIGIN");
res.set_raw_header("X-Content-Type-Options", "nosniff");
@@ -29,6 +31,32 @@ impl Fairing for AppHeaders {
}
}
pub struct Cached<R>(R, &'static str);
impl<R> Cached<R> {
pub fn long(r: R) -> Cached<R> {
// 7 days
Cached(r, "public, max-age=604800")
}
pub fn short(r: R) -> Cached<R> {
// 10 minutes
Cached(r, "public, max-age=600")
}
}
impl<'r, R: Responder<'r>> Responder<'r> for Cached<R> {
fn respond_to(self, req: &Request) -> response::Result<'r> {
match self.0.respond_to(req) {
Ok(mut res) => {
res.set_raw_header("Cache-Control", self.1);
Ok(res)
}
e @ Err(_) => e,
}
}
}
//
// File handling
//
@@ -49,6 +77,15 @@ pub fn read_file(path: &str) -> IOResult<Vec<u8>> {
Ok(contents)
}
pub fn read_file_string(path: &str) -> IOResult<String> {
let mut contents = String::new();
let mut file = File::open(Path::new(path))?;
file.read_to_string(&mut contents)?;
Ok(contents)
}
pub fn delete_file(path: &str) -> IOResult<()> {
let res = fs::remove_file(path);
@@ -64,7 +101,7 @@ pub fn delete_file(path: &str) -> IOResult<()> {
const UNITS: [&str; 6] = ["bytes", "KB", "MB", "GB", "TB", "PB"];
pub fn get_display_size(size: i32) -> String {
let mut size = size as f64;
let mut size: f64 = size.into();
let mut unit_counter = 0;
loop {
@@ -112,18 +149,6 @@ where
}
}
pub fn try_parse_string_or<S, T, U>(string: impl Try<Ok = S, Error = U>, default: T) -> T
where
S: AsRef<str>,
T: FromStr,
{
if let Ok(Ok(value)) = string.into_result().map(|s| s.as_ref().parse::<T>()) {
value
} else {
default
}
}
//
// Env methods
//
@@ -137,13 +162,6 @@ where
try_parse_string(env::var(key))
}
pub fn get_env_or<V>(key: &str, default: V) -> V
where
V: FromStr,
{
try_parse_string_or(env::var(key), default)
}
//
// Date util methods
//
@@ -200,7 +218,7 @@ impl<'de> Visitor<'de> for UpCaseVisitor {
let mut result_map = JsonMap::new();
while let Some((key, value)) = map.next_entry()? {
result_map.insert(upcase_first(key), upcase_value(&value));
result_map.insert(upcase_first(key), upcase_value(value));
}
Ok(Value::Object(result_map))
@@ -213,32 +231,32 @@ impl<'de> Visitor<'de> for UpCaseVisitor {
let mut result_seq = Vec::<Value>::new();
while let Some(value) = seq.next_element()? {
result_seq.push(upcase_value(&value));
result_seq.push(upcase_value(value));
}
Ok(Value::Array(result_seq))
}
}
fn upcase_value(value: &Value) -> Value {
if let Some(map) = value.as_object() {
fn upcase_value(value: Value) -> Value {
if let Value::Object(map) = value {
let mut new_value = json!({});
for (key, val) in map {
let processed_key = _process_key(key);
for (key, val) in map.into_iter() {
let processed_key = _process_key(&key);
new_value[processed_key] = upcase_value(val);
}
new_value
} else if let Some(array) = value.as_array() {
} else if let Value::Array(array) = value {
// Initialize array with null values
let mut new_value = json!(vec![Value::Null; array.len()]);
for (index, val) in array.iter().enumerate() {
for (index, val) in array.into_iter().enumerate() {
new_value[index] = upcase_value(val);
}
new_value
} else {
value.clone()
value
}
}