Compare commits

...

441 Commits
1.1.0 ... 1.9.0

Author SHA1 Message Date
Daniel García
21325b7523 Updated .env template 2019-04-27 20:14:37 +02:00
Daniel García
874f5c34bd Formatting 2019-04-26 22:08:26 +02:00
Daniel García
eadab2e9ca Updated dependencies 2019-04-26 22:07:00 +02:00
Daniel García
253faaf023 Use users duo host when required, instead of always using the global one 2019-04-15 13:07:23 +02:00
Daniel García
3d843a6a51 Merge pull request #460 from janost/organization-vault-purge
Fixed purging organization vault
2019-04-14 22:30:51 +02:00
janost
03fdf36bf9 Fixed purging organization vault 2019-04-14 22:12:48 +02:00
Daniel García
fdcc32beda Validate Duo credentials when custom 2019-04-14 22:05:05 +02:00
Daniel García
bf20355c5e Merge branch 'duo' 2019-04-14 22:02:55 +02:00
Daniel García
0136c793b4 Implement better user status API, in the future we'll probably want a way to disable users.
We should migrate from the empty password hash to a separate column then.
2019-04-13 00:01:52 +02:00
Daniel García
2e12114350 Always create the user when inviting from admin panel 2019-04-12 23:44:49 +02:00
Daniel García
f25ab42ebb Merge pull request #455 from ViViDboarder/get_users
Add new endpoint for retrieving all users
2019-04-11 20:42:44 +02:00
ViViDboarder
d3a8a278e6 Add new endpoint for retrieving all users 2019-04-11 11:24:53 -07:00
Daniel García
8d9827c55f Implement selection between global config and user settings for duo keys. 2019-04-11 18:40:03 +02:00
Daniel García
cad63f9761 Auto generate akey 2019-04-11 16:08:26 +02:00
Daniel García
bf446f44f9 Enable DATA_FOLDER to affect default CONFIG_FILE path 2019-04-11 15:41:13 +02:00
Daniel García
621f607297 Update dependencies and fix some warnings 2019-04-11 15:40:19 +02:00
Daniel García
d89bd707a8 Update vault release to show duo button 2019-04-07 18:58:32 +02:00
Daniel García
754087b990 Add global duo config and document options in .env template 2019-04-07 18:58:15 +02:00
Daniel García
cfbeb56371 Implement user duo, initial version
TODO:
- At the moment each user needs to configure a DUO application and input the API keys, we need to check if multiple users can register with the same keys correctly and if so we could implement a global setting.
- Sometimes the Duo frame doesn't load correctly, but canceling, reloading the page and logging in again seems to fix it for me.
2019-04-05 22:09:53 +02:00
Daniel García
3bb46ce496 Make the syslog crate non-optional when available 2019-04-02 22:35:22 +02:00
Daniel García
c5832f2b30 With the latest fern, syslog can be a config option instead of a build flag 2019-03-29 20:27:20 +01:00
Daniel García
d9406b0095 Update to web vault 2.10.0 2019-03-25 23:49:12 +01:00
Daniel García
2475c36a75 Implement log_level config option 2019-03-25 14:23:14 +01:00
Daniel García
c384f9c0ca Set default log level to Info, we don't use debug anyway and it just fills the logs with other crates info. 2019-03-25 14:21:50 +01:00
Daniel García
afbfebf659 Merge pull request #440 from BlackDex/mail-encoding
Fixed long e-mail message extending 1000 lines.
2019-03-25 13:09:51 +01:00
BlackDex
6b686c18f7 Fixed long e-mail message extending 1000 lines.
- Added quoted_printable crate to encode the e-mail messages.
- Change the way the e-mail gets build to use custom part headers.
2019-03-25 09:48:19 +01:00
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
Daniel García
1d034749f7 Fix AArch64 build by disabling yubico 2019-01-10 23:54:01 +01:00
Daniel García
08c55f636a Mention HTTPS needed for Chrome 2019-01-08 21:51:09 +01:00
Daniel García
0dc5d1a1c6 Of course I forgot the year... 2019-01-08 20:39:46 +01:00
Daniel García
1b11445bb2 Update dependencies and web vault 2019-01-08 20:28:17 +01:00
Daniel García
1596e1d4c5 Update README 2019-01-08 20:27:55 +01:00
Daniel García
320266606e Implement put collections 2019-01-08 20:27:28 +01:00
Daniel García
a0a08c4c5a Include IP in invalid admin token error 2019-01-08 16:17:18 +01:00
Daniel García
4309df8334 Only create invitations when SMTP is disabled, and ignore invitations if we have a token.
Disallow users from accepting invitation twice
2019-01-08 15:42:26 +01:00
Daniel García
f1161c65fb Make sure an invitation is created when reinviting 2019-01-08 14:05:05 +01:00
Daniel García
50eeb4f651 Remove whitespace before processing tokens 2019-01-07 20:37:14 +01:00
Daniel García
21b85b78b1 Changed reinvite check and removed obsolete comment 2019-01-07 15:29:57 +01:00
Daniel García
673adde9f1 Make the admin retype the user name before deleting 2019-01-07 14:48:10 +01:00
Daniel García
c9063a06b4 Print unauthorized error message 2019-01-07 02:18:51 +01:00
Daniel García
62b8500aae Fix missing .env error in Dockerfile, the .env file is not needed for the Docker images 2019-01-07 01:43:37 +01:00
Daniel García
f645e5381c Rename included .env file to .env.template and ignored .env 2019-01-06 22:50:30 +01:00
Daniel García
5e37471488 Merge pull request #323 from njfox/invite_accepted_email
Send email notifications when invitations are accepted/confirmed
2019-01-06 14:12:24 +01:00
Nick Fox
0a74e79cea Refactor generate_invite_claims, make org_name and org_id optional 2019-01-05 23:03:49 -05:00
Nick Fox
7db66f73f0 Refactor invited_by_email check 2019-01-05 13:46:45 -05:00
Nick Fox
2f5bdc23f6 Fix formatting and add vault link to notification emails 2019-01-05 13:36:08 -05:00
Daniel García
94adf063ad Merge pull request #326 from mprasil/vault_2.8.0
Update Vault to 2.8.0
2019-01-05 19:16:34 +01:00
Miroslav Prasil
77cada4085 Update Vault to 2.8.0 2019-01-05 18:54:59 +01:00
Nick Fox
cec28a85ac Update admin page to work with new invitation flow 2019-01-04 10:32:51 -05:00
Daniel García
5f49ecd7f3 Updated dependencies to use u2f crate directly, and some style changes 2019-01-04 00:25:38 +01:00
Nick Fox
736c0e62f2 Send emails to inviters/invitees when invites are accepted/confirmed 2019-01-02 22:20:39 -05:00
Nick Fox
43eb064351 Replace invite/reinvite email functions with generic send_email 2019-01-02 22:19:44 -05:00
Daniel García
c8af62ed48 Merge pull request #321 from njfox/migrate_readme_to_wiki
Remove redundant information from README after moving it to the wiki
2019-01-01 23:41:39 +01:00
Nick Fox
6e47535c2e Remove redundant docs files 2019-01-01 16:33:19 -05:00
Nick Fox
0448d98afc Remove redundant information from README after moving it to the wiki 2018-12-31 21:03:06 -05:00
Daniel García
b8326a15a3 Add build.rs to dockerfile 2018-12-31 14:07:12 +01:00
Daniel García
a6631c2ea8 Update documentation for admin page 2018-12-30 23:47:20 +01:00
Daniel García
30e768613b Start using rustfmt and some style changes to make some lines shorter 2018-12-30 23:34:31 +01:00
Daniel García
72ed05c4a4 Add build script to get some Git info to later show in admin panel, and add some metadata to Cargo.toml 2018-12-30 21:43:57 +01:00
Daniel García
adb8052689 Updated Error to implement Display and Debug, instead of using custom methods 2018-12-30 21:43:56 +01:00
Daniel García
1483829c94 Removed invalid todo 2018-12-30 21:43:56 +01:00
Daniel García
acb9d1b3c6 Remove config option for admin email, embdedded admin page, managed IO::Error, and added security and cache headers globally 2018-12-30 21:43:56 +01:00
Daniel García
301919d9d4 Modified navigation bar and corrected some comments 2018-12-30 21:43:55 +01:00
Daniel García
2bb0b15e04 Implemented better errors for JWT 2018-12-30 21:43:55 +01:00
Daniel García
250a2b340f Use new Errors in latest changes 2018-12-30 21:43:55 +01:00
Daniel García
b2fc0499f6 Finish invite functionality, and remove virtual organization 2018-12-30 21:40:26 +01:00
Daniel García
6a99849a1e Implemented proper error handling, now we can do user.save($conn)?; and it works.
In the future, maybe we can do the same with the `find_by_id` methods that return an Option.
2018-12-30 21:31:12 +01:00
Daniel García
172f1770cf Embed the icon in the binary, no need to download when it's not going to change 2018-12-30 21:31:12 +01:00
Daniel García
1b5134dfe2 Fixed delete user when 2FA is enabled, implemented delete user for admin panel, and the front-end part for invite user. Secured admin panel behind a configurable token. 2018-12-30 21:31:11 +01:00
Daniel García
5fecf09631 Initial version of admin panel, list users and reload user list works. No serious auth method yet, password is 'token123' 2018-12-30 21:31:11 +01:00
Daniel García
9a8cae836b Remove vault patch file, and point users to the vault builds repo for the patch 2018-12-30 21:31:03 +01:00
Daniel García
7d7d8afed9 Merge pull request #318 from njfox/reinvite_endpoint
Add email reinvite endpoint
2018-12-30 21:22:26 +01:00
Nick Fox
f20c4705d9 Refactor invite claims and disallow reinvites to virtual_org 2018-12-30 00:19:01 -05:00
Nick Fox
3142d8d01f Add more detail to invitation not found error 2018-12-29 23:28:19 -05:00
Nick Fox
84fa5a4ed6 Implement reinvite endpoint 2018-12-29 23:24:38 -05:00
Daniel García
004a3f891f Merge pull request #315 from aksdb/master
Restrict join on users_collections to current user (fixes #313)
2018-12-28 21:06:30 +01:00
Daniel García
e197f372b5 Merge pull request #314 from mprasil/find_by_user_uuid
Rewrite find_by_user_uuid to use one query
2018-12-28 15:47:31 +01:00
Andreas Schneider
e7ea5097f4 Restrict join on users_collections to current user (fixes #313) 2018-12-28 15:25:51 +01:00
Miroslav Prasil
8451a70de6 Rewrite find_by_user_uuid to use one query 2018-12-27 18:56:01 +01:00
Daniel García
9f0357ce82 Merge pull request #312 from njfox/logging_timestamp
Change logging timestamp format so fail2ban can parse it
2018-12-26 20:20:50 +01:00
Nick Fox
cd6e4a0ebd Remove reference to syslog 2018-12-26 12:14:54 -05:00
Nick Fox
473740c13a Update fail2ban documentation for new logging format 2018-12-26 12:07:16 -05:00
Nick Fox
ec715d78fb Change log timestamp format so fail2ban can parse it 2018-12-26 11:54:31 -05:00
Daniel García
3f5df3ef8d Merge pull request #311 from njfox/update_documentation
Document new features and update outdated parts of README
2018-12-26 17:27:10 +01:00
Nick Fox
f694d6f839 Add missing trailing slash 2018-12-26 10:30:21 -05:00
Nick Fox
b20516d645 Update ToC with Logging section 2018-12-26 10:28:46 -05:00
Nick Fox
c04300651a Document new logging setup 2018-12-25 23:00:50 -05:00
Nick Fox
2f058d3ff5 Remove old/incorrect statement about SMTP not being implemented 2018-12-25 22:30:35 -05:00
Nick Fox
582f967a59 Update invitations documentation for new email invitations 2018-12-25 22:29:31 -05:00
Daniel García
00bc355220 Merge pull request #308 from njfox/invite_existing_user_fix
Fix email invitations for existing users and update README
2018-12-23 22:31:00 +01:00
Nick Fox
e3395ee910 Mention that invite links are valid for 5 days only 2018-12-23 15:25:05 -05:00
Nick Fox
cb78ba9bb2 Document domain/email invitation requirements 2018-12-23 15:21:19 -05:00
Nick Fox
b0ac640d8b Use JWT to validate existing user invites 2018-12-23 15:15:44 -05:00
Daniel García
2b24b17609 Merge pull request #295 from njfox/invite_emails
Add Email Invite Functionality
2018-12-21 16:04:19 +01:00
Nick Fox
2cd736ab81 Validate JWT if a user registers with SMTP invites enabled 2018-12-20 22:16:41 -05:00
Nick Fox
99256b9b3a Prefix unused params with underscore 2018-12-20 21:37:03 -05:00
Nick Fox
26bf7bc12f Use upstream jslib invite/registration workflow 2018-12-18 23:16:03 -05:00
Daniel García
b3ec8f2611 Merge pull request #302 from tycho/icon-cache-ttl
implement TTLs for icon cache
2018-12-18 23:34:16 +01:00
Steven Noonan
a55c048a62 icons: implement positive/negative cache TTLs
Signed-off-by: Steven Noonan <steven@uplinklabs.net>
2018-12-18 13:33:32 -08:00
Steven Noonan
848cd1dbec add environment variables for ICON_CACHE_TTL and ICON_CACHE_NEGTTL
These aren't used yet, but will be utilized by the icon caching service
in a subsequent patch.

Signed-off-by: Steven Noonan <steven@uplinklabs.net>
2018-12-18 13:33:31 -08:00
Daniel García
149e69414f Merge pull request #293 from dheimerl/patch-1
Update web.rs
2018-12-18 19:00:43 +01:00
dheimerl
9a7d3634d5 Changed frame-ancestors to use 'self' 2018-12-18 10:19:35 -06:00
dheimerl
7f7c936049 Fixed web.rs 2018-12-17 22:59:53 -06:00
Nick Fox
9479108fb7 Remove CONFIG.email_invitations 2018-12-17 17:10:09 -05:00
Nick Fox
042c1072d9 Remove CONFIG.email_invitation option 2018-12-17 17:02:15 -05:00
Daniel García
1f4edb38e6 Add extra headers to Nginx (#303) 2018-12-17 19:29:07 +01:00
Daniel García
371017b547 Merge pull request #294 from i-ghost/feature/add-systemd-notes
Add systemd setup instructions to README.md
2018-12-16 23:55:56 +01:00
i-ghost
bc20592712 Revise per dani-garcia/bitwarden_rs#294 2018-12-16 21:09:59 +00:00
Daniel García
37a6da3443 Merge pull request #296 from jeslinmx/docsupdate
updated fail2ban setup to match changes in logformat for 1.5.0
2018-12-16 20:06:46 +01:00
Daniel García
53bd169462 Merge pull request #297 from mprasil/vault_2.7.1
Update vault to 2.7.1
2018-12-16 20:06:15 +01:00
Daniel García
5a9aab1a32 Implement fromform, and ignore case and underscores, fixes #298 2018-12-16 20:00:16 +01:00
Miroslav Prasil
23eadf2c9a Update vault t o2.7.1 2018-12-16 16:00:51 +00:00
jes
d5dfda8905 updated fail2ban setup to match changes in logformat for 1.5.0 2018-12-16 22:30:25 +08:00
Nick Fox
e245e965ba Fix broken rebase 2018-12-15 22:45:39 -05:00
i-ghost
ce15c7ffba Add systemd setup instructions to README.md 2018-12-15 22:25:17 +00:00
dheimerl
037eb0b790 Update web.rs
Add frame-ancestors to allow U2F to work in Chrome (and possibly Firefox) extension
2018-12-15 13:23:07 -06:00
Nick Fox
4910b14d57 Implement email invitations and registration workflow 2018-12-14 21:56:00 -05:00
Nick Fox
d428120ec6 Add email_invitations config option 2018-12-14 21:54:44 -05:00
Nick Fox
e2907f4250 Add invite email functionality 2018-12-14 21:54:03 -05:00
Nick Fox
680f5e83d8 Add Invite JWT struct and supporting functions 2018-12-14 21:52:16 -05:00
Daniel García
a335bcd682 Merge pull request #291 from mqus/patch-1
Add memory usage note to the vault build documentation
2018-12-14 22:56:44 +01:00
mqus
76c510c5b6 move memory note 2018-12-14 20:32:11 +01:00
mqus
4635d62e2c Add memory usage note to vault build process
As there were multiple people having problems with the build I thought that maybe adding a note would help.
2018-12-14 15:04:46 +01:00
Daniel García
65e36e2931 Merge pull request #289 from mprasil/alpine-fix
Fix failing alpine build
2018-12-14 12:33:17 +01:00
Miroslav Prasil
063dfcf487 FIx failing alpine build 2018-12-13 23:16:36 +00:00
Daniel García
d3e4fb88ee Fix wrong folder name 2018-12-13 18:30:01 +01:00
Daniel García
7ebba736cb Updated dockerfiles to use compiled vaults 2018-12-13 18:19:26 +01:00
Daniel García
a1272c7190 Merge branch 'rocket-0.4' 2018-12-13 17:49:55 +01:00
Daniel García
0a6b4e9961 Update BUILD.md 2018-12-13 17:46:08 +01:00
Daniel García
e66edc1ce9 Merge pull request #285 from itr6/master
Add line for people using docker commands instead of docker-compose
2018-12-13 01:07:17 +01:00
Daniel García
4e827e4f8a Implement better retry and use it while saving device 2018-12-12 22:15:54 +01:00
itr6
295985de7c Update README.md 2018-12-12 14:38:57 -06:00
itr6
a48611aa6d Update README.md 2018-12-12 14:38:23 -06:00
Daniel García
f713e2e092 Merge pull request #284 from Algebro7/log_successful_logins
Close #264. Username and IP address is logged on successful authentication
2018-12-11 22:24:04 +01:00
algebro
e26e2319da Close #264. Usernames and IP addresses are logged on successful authentication 2018-12-11 15:20:06 -05:00
Daniel García
f2ab25085d Updated dependencies, and dockerfiles to use NodeJS 10 LTS 2018-12-11 16:42:52 +01:00
Daniel García
7adc045b80 Updated IP logging to use client_ip, to match old remote behavior.
Improved error logging, now it won't show a generic error message in some situations.
Removed delete device, which is not needed as it will be overwritten later.
Logged more info when an error occurs saving a device.
Added orgmanager to JWT claims.
2018-12-09 17:58:38 +01:00
Daniel García
19754c967f More changes to the push token, and filtered multipart logs 2018-12-07 18:25:18 +01:00
Daniel García
738ad2127b Fixed some clippy linting issues 2018-12-07 15:01:29 +01:00
Daniel García
cb930a0858 Remove some required values during login, now uses default values 2018-12-07 14:32:40 +01:00
Daniel García
94810c106a Migrate to rust 2018 edition 2018-12-07 02:05:45 +01:00
Daniel García
2fde4e6933 Implemented proper logging, with support for file logging, timestamp and syslog (this last one is untested) 2018-12-06 20:35:25 +01:00
Daniel García
259a2f2982 Updated rocket to final release 2018-12-06 20:15:22 +01:00
Daniel García
dc1bb6de20 Update device push token methods to more closely follow the official server response 2018-12-06 16:28:36 +01:00
Daniel García
e34f75c267 Make sure the dockerfile builds the project, instead of assuming the cached dummy is valid 2018-12-04 13:55:37 +01:00
Daniel García
2129946d14 Updated deps and web vault to 2.6.1 2018-12-03 20:28:13 +01:00
Daniel García
bdcdb08fc1 Merge branch 'master' into rocket-0.4 2018-12-01 14:58:39 +01:00
Daniel García
8b5d97790f Updated rocket to rc2 and rest of dependencies 2018-12-01 14:29:19 +01:00
Daniel García
cac7fb145b Merge pull request #274 from itr6/master
Fail2Ban Setup & Walkthrough
2018-11-29 23:20:30 +01:00
itr6
6990525e8a Update README.md 2018-11-29 10:52:47 -06:00
itr6
55579261b7 Update README.md 2018-11-29 10:49:20 -06:00
itr6
70c20f7f52 Update README.md 2018-11-29 10:48:41 -06:00
itr6
b56a905322 Update README.md 2018-11-29 10:42:53 -06:00
Daniel García
e177160ee9 Update web vault to 2.6.0 2018-11-27 17:29:09 +01:00
Daniel García
6364c05789 Fix attachments during key rotation, add individual attachment key 2018-11-27 17:24:12 +01:00
Daniel García
f71f10eac6 Implemented key rotation with the latest vault 2018-11-24 23:00:41 +01:00
Daniel García
0f6ab01f77 Merge pull request #268 from mprasil/master
fix bad path in Alpine Dockerfile
2018-11-22 03:06:26 +01:00
Miroslav Prasil
0935cb90a4 fix bad path in Alpine Dockerfile 2018-11-21 23:35:42 +00:00
Daniel García
e86cac984a Merge pull request #263 from mprasil/master
Update dockerfile for alpine, armv7 and aarch64
2018-11-21 15:57:11 +01:00
Daniel García
f1acc1e05a Merge branch 'master' into rocket-0.4 2018-11-21 15:35:34 +01:00
Daniel García
3d36ac4601 Remove unwrap in connection_lost 2018-11-21 15:07:18 +01:00
Miroslav Prasil
bf1c96695b Update dockerfile for alpine, armv7 and aarch64 2018-11-20 14:55:19 +00:00
Daniel García
9ecb29883c Merge branch 'master' into rocket-0.4
# Conflicts:
#	Cargo.lock
2018-11-19 20:59:41 +01:00
Daniel García
8b3e87cfe0 Update lockfile to fix yubico error 2018-11-19 20:58:59 +01:00
Daniel García
e5af230315 Merge branch 'master' into rocket-0.4
# Conflicts:
#	Cargo.toml
2018-11-19 20:45:42 +01:00
Daniel García
a1da82c868 Add online feature to yubico 2018-11-19 20:45:11 +01:00
Daniel García
981a324027 Merge branch 'master' into rocket-0.4
# Conflicts:
#	src/api/core/mod.rs
2018-11-19 20:30:19 +01:00
Daniel García
00abd4c853 Add create endpoint, fixes #253, fixes #261 2018-11-19 20:27:49 +01:00
Daniel García
a4550e51ea Update dependencies and add /ciphers/create 2018-11-19 20:21:02 +01:00
Daniel García
5edbd0e952 Merge branch 'master' into rocket-0.4
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/api/core/mod.rs
2018-11-19 19:52:43 +01:00
Daniel García
1d4944b88e Merge pull request #258 from Bruceforce/master
Update backup section of README.md
2018-11-19 19:36:10 +01:00
10
660c8f8d7e Use dockerhub instead of gitlab registry for README.md 2018-11-18 14:06:08 +01:00
10
478fc0c9dd Merge remote-tracking branch 'origin/master' 2018-11-18 03:23:16 +01:00
10
a2de4ce40e Improved the backup section of sqlite3 database 2018-11-18 03:22:30 +01:00
Daniel García
17052b665f Merge pull request #257 from Step7750/fix-nfc-mobile
Fixes NFC Response for Mobile Yubikey OTP Login
2018-11-17 15:28:41 +01:00
Stepan Fedorko-Bartos
f344dbaad4 Fixes NFC Response for Mobile Login 2018-11-17 01:25:07 -07:00
Daniel García
c5c9e3fd65 Merge pull request #254 from Step7750/master
Adds Yubikey OTP Support
2018-11-16 20:39:03 +01:00
Stepan Fedorko-Bartos
aba9c28226 Disable Yubikey 2FA if 0 Keys Provided 2018-11-16 12:07:00 -07:00
Stepan Fedorko-Bartos
82e2b8a8c0 Code style changes 2018-11-16 11:52:01 -07:00
Stepan Fedorko-Bartos
5a8d5e426d Switches to Downstream yubico with Optional libusb 2018-11-16 11:28:20 -07:00
Daniel García
f9ecb7201b Merge pull request #256 from mprasil/fix_accepted_sync
Prevent accepted user from seeing ciphers until confirmed (fixes #196)
2018-11-16 16:17:21 +01:00
Miroslav Prasil
044cf19913 Prevent accepted user from seeing ciphers until confirmed (fixes #196) 2018-11-16 14:21:26 +00:00
Stepan Fedorko-Bartos
2c233cda8b Adds Yubikey OTP Support to Readme 2018-11-15 18:59:16 -07:00
Stepan Fedorko-Bartos
6d735806c0 Ensures Yubico Creds are set when opening Yubikey Modal 2018-11-15 18:58:44 -07:00
Stepan Fedorko-Bartos
2433d39df5 Allows Custom Yubico OTP Server 2018-11-15 18:54:53 -07:00
Stepan Fedorko-Bartos
9e0e4b13c5 Adds Yubikey OTP Support 2018-11-15 18:43:09 -07:00
Stepan Fedorko-Bartos
e66436625c Adds Yubico Client ID and Secret Key Env Vars 2018-11-15 18:40:27 -07:00
Stepan Fedorko-Bartos
24a4478b5c Adds yubico-rs library dep 2018-11-15 18:34:17 -07:00
Daniel García
f84cbeaaf8 Merge branch 'master' into rocket-0.4
# Conflicts:
#	src/db/models/organization.rs
2018-11-14 16:14:49 +01:00
Daniel García
3cb911a52f Merge pull request #243 from mprasil/vault_2.5.0
Bump vault version to 2.5.0 [wip]
2018-11-14 16:12:18 +01:00
Miroslav Prasil
dd684753d0 Fix gt() 2018-11-13 21:38:56 +00:00
Miroslav Prasil
f3e6cc6ffd Set PartialOrd to consider invalid i32 UserOrgType lower than anything 2018-11-13 16:34:21 +00:00
Miroslav Prasil
b94f4db52a Fix #242 2018-11-13 15:34:37 +00:00
Miroslav Prasil
66a4c5d48b Implement comparison between i32 and UserOrgType 2018-11-12 17:13:25 +00:00
Miroslav Prasil
b4e222d598 Bump vault version to 2.5.0 2018-11-12 10:41:47 +00:00
Daniel García
dd00591082 Add info about how to fix #176 2018-11-09 16:24:45 +01:00
Daniel García
4638786507 Merge branch 'master' into rocket-0.4
# Conflicts:
#	src/api/core/mod.rs
2018-11-09 16:06:24 +01:00
Daniel García
1e9dd2fd4e Merge pull request #244 from RomanHargrave/multiple-u2f
Make U2F work with vault 2.4.0 changes
2018-11-09 15:47:29 +01:00
Roman Hargrave
62bc58e145 Clean up after u2f endpoint split 2018-11-09 00:27:43 -06:00
Roman Hargrave
760e0ab805 Initial u2f fix 2018-11-09 00:00:31 -06:00
Daniel García
6eb1c3d638 Fixed change in organizations header, now Request::get_param() are indexed by segment, not dynamic parameter. 2018-11-06 16:53:34 +01:00
Miroslav Prasil
f408efc927 Also remove libs from other platforms Dockerfiles 2018-11-03 18:56:41 +01:00
Miroslav Prasil
9b20247fc2 Remove libs from Dockerfile 2018-11-03 18:54:36 +01:00
Daniel García
8f5bfe7938 Merge pull request #240 from mprasil/balenalib
Switch from resin to balenalib
2018-11-03 14:48:38 +01:00
Miroslav Prasil
b359df7045 Switch from resin to balenalib 2018-11-03 10:25:15 +01:00
Daniel García
e844f41abc Make the docker images use port 80 (instead of 8000, which is the default in rocket 0.4) 2018-11-01 19:25:10 +01:00
Daniel García
bc532f54d5 Improve login query parsing 2018-11-01 19:25:10 +01:00
Daniel García
c673370103 Updated bw_rs to Rocket version 0.4-rc1 2018-11-01 19:25:09 +01:00
Daniel García
f1b1000600 Merge pull request #239 from mprasil/admin_warning
Improve the admin account warning.
2018-11-01 19:03:05 +01:00
Miroslav Prasil
c0e248c457 Improve the admin account warning. 2018-11-01 18:54:33 +01:00
mprasil
f510a1b060 Merge pull request #236 from xMateMCPE/patch-1
Grammar fixes to README.md
2018-10-28 23:06:10 +00:00
xMateMCPE
fafc3883c6 Update README.md 2018-10-28 22:32:32 +00:00
Daniel García
1bdb98d139 Merge pull request #234 from icicimov/kubernetes-aws
Add Kubernetes deployment reference
2018-10-28 16:36:42 +01:00
icicimov
2f5ca88fb1 Readme typo fix 2018-10-28 18:16:22 +11:00
icicimov
e7a24159c5 Add Kubernetes deployment reference with proper section and short description 2018-10-27 12:42:56 +11:00
icicimov
e056cc8178 Add Kubernetes deployment reference 2018-10-26 22:37:35 +11:00
Daniel García
8ce4c79612 Merge pull request #233 from Toucan-Sam/patch-2
Update PROXY.md
2018-10-26 00:37:34 +02:00
Toucan-Sam
77d9641323 Update PROXY.md
Removed: `include conf.d/proxy-confs/proxy.conf;` lines because they're specific to user (shauder) and will break nginx if copy-pasted/don't exist.
Changed: Moved listen value and server_name to top as is standard for nginx configs
Changed: Commented out SSL config as it's specific to user (shauder) and will break if copy-pasted/don't exist. But is still useful and a good idea for simplifying nginx config.
Changed: Rearranged location blocks because OCD. First /, then /notifications/hub, then /notifications/hub/negotiate because it looks nicer in a tree where each location grows.
2018-10-25 19:45:22 +13:00
Daniel García
31e4237247 Merge pull request #231 from janost/fix-save-equivalent-domains
Accept PUT and POST on /settings/domains, returns JsonResult, fixes saving Custom Equivalent Domains
2018-10-23 01:30:28 +02:00
janost
c32c65d367 Accept PUT and POST on /settings/domains, returns JsonResult, fixes saving Custom Equivalent Domains 2018-10-23 00:32:43 +02:00
Daniel García
0a4dbaf307 Merge pull request #226 from janost/fix-sync-without-query-string
Fix /sync without query string
2018-10-19 01:34:32 +02:00
janost
daa66b08dc Fix /sync without query string 2018-10-19 00:54:40 +02:00
Daniel García
d613fa1e68 Merge pull request #225 from janost/sync-exclude-domains
Don't send Domains if excludeDomains=true on /sync
2018-10-17 23:49:09 +02:00
janost
55fbd8d468 Don't send Domains if excludeDomains=true on /sync 2018-10-17 23:22:07 +02:00
Daniel García
adf40291e8 Update web vault to 2.4.0 2018-10-16 16:20:56 +02:00
Daniel García
acfc900997 Merge pull request #224 from fbartels/patch-2
refine backup instructions
2018-10-15 22:56:16 +02:00
Felix Bartels
0a08b1afc8 refine backup instructions
as mentioned in https://github.com/dani-garcia/bitwarden_rs/issues/223
2018-10-15 21:47:22 +02:00
Daniel García
eb48a3fac2 Merge pull request #221 from mprasil/ws_disable
Disable WebSockets negotiation by default
2018-10-15 16:20:07 +02:00
Miroslav Prasil
2e7fa6440b Do not spawn WS thread if it's disabled 2018-10-15 15:08:15 +01:00
Miroslav Prasil
9ecc98c3cc Disable WebSockets negotiation by default 2018-10-14 23:25:16 +01:00
Daniel García
02fd68d63b Merge pull request #218 from janost/refactor-folder-save
Folder::save() should return QueryResult instead of bool
2018-10-14 20:07:27 +02:00
Daniel García
235bce1ecb Merge pull request #220 from janost/refactor-user-save
User::save() should return QueryResult instead of bool
2018-10-14 20:07:04 +02:00
janost
e985221b50 User::save() should return QueryResult instead of bool 2018-10-14 19:33:12 +02:00
janost
77cf63c06d Folder::save() should return QueryResult instead of bool 2018-10-14 18:25:04 +02:00
Daniel García
faec050a6d Merge pull request #217 from janost/refactor-device-save
Device::save() should return QueryResult instead of bool
2018-10-14 17:35:59 +02:00
Daniel García
22304f4925 Merge pull request #219 from janost/refactor-organization-save
Organization::save() and UserOrganization::save() should return QueryResult instead of bool
2018-10-14 17:35:30 +02:00
janost
58a78ffa54 Device::save() should return QueryResult instead of bool 2018-10-14 16:17:37 +02:00
janost
64f6c60bfd Organization::save() and UserOrganization::save() should return QueryResult instead of bool 2018-10-14 16:04:23 +02:00
Daniel García
e0614620ef Merge pull request #216 from mprasil/superuser
Implement poor man's admin panel
2018-10-13 17:16:06 +02:00
Miroslav Prasil
a28caa33ef Implement poor man's admin panel 2018-10-12 15:20:10 +01:00
Daniel García
ce4fedf191 Change error response to be closer to upstream 2018-10-10 20:37:04 +02:00
Daniel García
f2078a3849 Merge pull request #213 from janost/refactor-collectioncipher-save-delete
CollectionCipher::save() and delete() should return QueryResult instead of bool
2018-10-07 16:06:47 +02:00
janost
5292d38c73 CollectionCipher::save() and delete() should return QueryResult instead of bool 2018-10-07 11:06:11 +02:00
Daniel García
1049646e27 Merge pull request #210 from janost/cipher-save-refactor
Cipher::save() should return QueryResult instead of bool
2018-10-06 16:08:04 +02:00
janost
380cf06211 Cipher::save() should return QueryResult instead of bool 2018-10-06 14:13:49 +02:00
mprasil
1f35ef2865 Merge pull request #209 from janost/resend-invitation-error-message
Return proper error message for org reinvite
2018-10-05 23:21:08 +01:00
janost
c29bc9309a Return proper error message for org reinvite 2018-10-05 12:29:41 +02:00
Daniel García
7112c86471 Updated dependencies, removed valid mail check (now done by lettre), and updated global domains file 2018-10-04 00:01:04 +02:00
Daniel García
2aabf14372 Merge pull request #206 from mprasil/collection_revision
Collection update updates User revision
2018-10-01 19:31:43 +02:00
Daniel García
77ff9c91c5 Merge pull request #207 from mprasil/continuation_token
Add continuation token when we return object list
2018-10-01 19:31:11 +02:00
Miroslav Prasil
d9457e929c Add continuation token when we return object list 2018-10-01 17:55:48 +01:00
Miroslav Prasil
86b49856a7 Handle return value from Collection::save() 2018-10-01 17:50:31 +01:00
Miroslav Prasil
54f54ee845 Update revision for users on collection save 2018-10-01 17:04:15 +01:00
Daniel García
015bd28cc2 Merge pull request #201 from mprasil/aarch64
Add dockerfile for aarch64 (arm64)
2018-10-01 17:40:20 +02:00
Daniel García
990c83a037 Merge pull request #204 from mprasil/org_improvements
Org improvements
2018-10-01 17:40:14 +02:00
Miroslav Prasil
c3c74506a7 Add missing fields to returned Org json 2018-10-01 16:00:11 +01:00
Miroslav Prasil
fb4e6bab14 Clean up the share_collection handling 2018-10-01 15:59:10 +01:00
Miroslav Prasil
fe38f95f15 Add dockerfile for aarch64 (arm64) 2018-09-28 13:01:47 +01:00
Daniel García
9eaa9c1a17 Add WEBSOCKET_ADDRESS config option (Fixes #199) 2018-09-28 13:50:04 +02:00
Daniel García
8ee681c4a3 Merge pull request #200 from mprasil/copy_fix
Remove unecessary copy from armv7 Dockerfile
2018-09-28 13:31:40 +02:00
Miroslav Prasil
08aee97c1d Remove unecessary copy from armv7 Dockerfile 2018-09-28 09:49:10 +01:00
Daniel García
2bb6482bec Merge branch 'openssl-fix' 2018-09-20 22:52:58 +02:00
Daniel García
c169095128 Update dependencies to point to upstream lettre 2018-09-20 22:45:19 +02:00
Daniel García
b1397c95ca Remove unnecessary path in PROXY.md 2018-09-19 22:33:12 +02:00
Daniel García
3df31e3464 Temp fix for OpenSSL 1.1.1 compatibility 2018-09-19 21:45:50 +02:00
Daniel García
638a0fd3c3 Updated dependencies 2018-09-19 21:43:03 +02:00
Daniel García
ebb66c374e Implement KDF iterations change (Fixes #195) 2018-09-19 17:30:14 +02:00
Daniel García
89e3c41043 Merge pull request #191 from mprasil/vault_2.3.0
Update Vault to v2.3.0
2018-09-18 16:03:10 +02:00
Miroslav Prasil
3da410ef71 Update Vault to v2.3.0 2018-09-18 13:53:25 +01:00
Daniel García
2dccbd3412 Merge pull request #190 from mprasil/invite_readme
Update the Invitation workflow documentation
2018-09-18 14:36:39 +02:00
Daniel García
2ff529ed99 Merge pull request #189 from mprasil/delete_fix
Add alias for DELETE call on accounts
2018-09-18 14:36:31 +02:00
Miroslav Prasil
4fae1e4298 Update the Invitation workflow documentation 2018-09-18 11:49:20 +01:00
Miroslav Prasil
f7951b44ba Add alias for DELETE call on accounts 2018-09-18 11:13:45 +01:00
Daniel García
ff8eeff995 Merge pull request #184 from mprasil/code_block_fix
Fixed code block, added some formatting
2018-09-16 15:44:05 +02:00
Miroslav Prasil
00019dc356 Added some formating and link 2018-09-16 12:36:08 +01:00
Miroslav Prasil
404fe5321e Fixed code block 2018-09-16 12:27:29 +01:00
Daniel García
e7dd239d20 Merge pull request #182 from dobunzli/master
Update README.md
2018-09-15 20:03:58 +02:00
Daniel García
071f3370e3 Merge pull request #183 from jkaberg/traefik_example_proxy
traefik proxy example
2018-09-15 20:03:34 +02:00
Joel Kåberg
ee321be579 traefik example 2018-09-14 23:22:38 +02:00
dobunzli
eb61425da5 Update README.md
Added infos about enabling https when softwares getting certs are using symlinks
2018-09-14 22:39:58 +02:00
Daniel García
b75ba216d1 Return default prelogin values when the user doesn't exist 2018-09-13 23:04:52 +02:00
Daniel García
8651df8c2a Fixed some lint issues 2018-09-13 21:55:23 +02:00
Daniel García
948554a20f Added config option for websocket port, and reworked the config parsing a bit.
Added SMTP_FROM config to examples and made it mandatory, it doesn't make much sense to not specify the from address.
2018-09-13 20:59:51 +02:00
Daniel García
9cdb605659 Include more proxy examples 2018-09-13 17:08:16 +02:00
Daniel García
928e2424c0 Updated dependencies and fixed errors 2018-09-13 16:05:13 +02:00
Daniel García
a01fee0b9f Merge branch 'ws'
# Conflicts:
#	Cargo.toml
#	src/api/core/ciphers.rs
#	src/main.rs
2018-09-13 15:59:45 +02:00
Daniel García
67adfee5e5 Some documentation 2018-09-11 17:27:04 +02:00
Daniel García
d66d4fd87f Add error message when the proxy doesn't route websockets correctly 2018-09-11 17:09:33 +02:00
Daniel García
434551e012 Merge pull request #171 from shauder/ws
Expose 3012 in docker build file for notifications
2018-09-04 21:18:16 +02:00
Daniel García
69dcbdd3b2 Merge branch 'master' into ws 2018-09-04 17:46:38 +02:00
Shane A. Faulkner
422f7ccfa8 Expose 3012 in docker build file for notifications 2018-09-04 10:22:17 -05:00
Daniel García
f8ae5013cb Merge pull request #167 from shauder/ws
Add support for cipher update notifications
2018-09-02 00:17:40 +02:00
Shane A. Faulkner
d8e5e53273 Add notifications for cipher delete and create 2018-09-01 10:59:13 -05:00
Shane A. Faulkner
b6502e9e9d Add support for CipherUpdate notifications 2018-08-31 23:30:53 -05:00
Daniel García
d70864ac73 Initial version of websockets notification support.
For now only folder notifications are sent (create, rename, delete).
The notifications are only tested between two web-vault sessions in different browsers, mobile apps and browser extensions are untested.

The websocket server is exposed in port 3012, while the rocket server is exposed in another port (8000 by default). To make notifications work, both should be accessible in the same port, which requires a reverse proxy.

My testing is done with Caddy server, and the following config:

```
localhost {

    # The negotiation endpoint is also proxied to Rocket
    proxy /notifications/hub/negotiate 0.0.0.0:8000 {
        transparent
    }

    # Notifications redirected to the websockets server
    proxy /notifications/hub 0.0.0.0:3012 {
        websocket
    }

    # Proxy the Root directory to Rocket
    proxy / 0.0.0.0:8000 {
        transparent
    }
}
```

This exposes the service in port 2015.
2018-08-30 17:58:53 +02:00
76 changed files with 8863 additions and 4593 deletions

View File

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

50
.env
View File

@@ -1,50 +0,0 @@
## Bitwarden_RS Configuration File
## Uncomment any of the following lines to change the defaults
## Main data folder
# DATA_FOLDER=data
## Individual folders, these override %DATA_FOLDER%
# DATABASE_URL=data/db.sqlite3
# RSA_KEY_FILENAME=data/rsa_key
# ICON_CACHE_FOLDER=data/icon_cache
# ATTACHMENTS_FOLDER=data/attachments
## Web vault settings
# WEB_VAULT_FOLDER=web-vault/
# WEB_VAULT_ENABLED=true
## Controls if new users can register
# SIGNUPS_ALLOWED=true
## 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
## Controls the PBBKDF password iterations to apply on the server
## The change only applies when the password is changed
# PASSWORD_ITERATIONS=100000
## Whether password hint should be sent into the error response when the client request it
# SHOW_PASSWORD_HINT=true
## 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
## For U2F to work, the server must use HTTPS, you can use Let's Encrypt for free certs
# DOMAIN=https://bw.domain.tld:8443
## Rocket specific settings, check Rocket documentation to learn more
# ROCKET_ENV=staging
# ROCKET_ADDRESS=0.0.0.0 # Enable this to test mobile app
# ROCKET_PORT=8000
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
## Mail specific settings, if SMTP_HOST is specified, SMTP_USERNAME and SMTP_PASSWORD are mandatory
# SMTP_HOST=smtp.domain.tld
# SMTP_PORT=587
# SMTP_SSL=true
# SMTP_USERNAME=username
# SMTP_PASSWORD=password

146
.env.template Normal file
View File

@@ -0,0 +1,146 @@
## Bitwarden_RS Configuration File
## Uncomment any of the following lines to change the defaults
## Main data folder
# DATA_FOLDER=data
## Individual folders, these override %DATA_FOLDER%
# DATABASE_URL=data/db.sqlite3
# RSA_KEY_FILENAME=data/rsa_key
# 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")
# ICON_CACHE_NEGTTL=259200
## Web vault settings
# 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
## Enable extended logging
## This shows timestamps and allows logging to file and to syslog
### To enable logging to file, use the LOG_FILE env variable
### To enable syslog, use the USE_SYSLOG env variable
# EXTENDED_LOGGING=true
## Logging to file
## This requires extended logging
## It's recommended to also set 'ROCKET_CLI_COLORS=off'
# LOG_FILE=/path/to/log
## Logging to Syslog
## This requires extended logging
## It's recommended to also set 'ROCKET_CLI_COLORS=off'
# USE_SYSLOG=false
## Log level
## Change the verbosity of the log output
## Valid values are "trace", "debug", "info", "warn", "error" and "off"
## This requires extended logging
# LOG_LEVEL=Info
## 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
## Token for the admin interface, preferably use a long random string
## 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
## Controls the PBBKDF password iterations to apply on the server
## The change only applies when the password is changed
# PASSWORD_ITERATIONS=100000
## Whether password hint should be sent into the error response when the client request it
# SHOW_PASSWORD_HINT=true
## Domain settings
## The domain must match the address from where you access the server
## 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
## Yubico (Yubikey) Settings
## Set your Client ID and Secret Key for Yubikey OTP
## You can generate it here: https://upgrade.yubico.com/getapikey/
## You can optionally specify a custom OTP server
# YUBICO_CLIENT_ID=11111
# YUBICO_SECRET_KEY=AAAAAAAAAAAAAAAAAAAAAAAA
# YUBICO_SERVER=http://yourdomain.com/wsapi/2.0/verify
## Duo Settings
## You need to configure all options to enable global Duo support, otherwise users would need to configure it themselves
## Create an account and protect an application as mentioned in this link (only the first step, not the rest):
## https://help.bitwarden.com/article/setup-two-step-login-duo/#create-a-duo-security-account
## Then set the following options, based on the values obtained from the last step:
# DUO_IKEY=<Integration Key>
# DUO_SKEY=<Secret Key>
# DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above,
## ignoring the fields that ask for the values that you already configured beforehand.
## Rocket specific settings, check Rocket documentation to learn more
# ROCKET_ENV=staging
# ROCKET_ADDRESS=0.0.0.0 # Enable this to test mobile app
# ROCKET_PORT=8000
# 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
# SMTP_PASSWORD=password

2
.gitignore vendored
View File

@@ -10,7 +10,7 @@ data
*.iml
# Environment file
# .env
.env
# Web vault
web-vault

View File

@@ -1,7 +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
# Nothing to install
install: true
script: cargo build --all-features

View File

@@ -1,70 +0,0 @@
# Build instructions
## Dependencies
- `Rust nightly` (strongly recommended to use [rustup](https://rustup.rs/))
- `OpenSSL` (should be available in path, install through your system's package manager or use the [prebuilt binaries](https://wiki.openssl.org/index.php/Binaries))
- `NodeJS` (required to build the web-vault, (install through your system's package manager or use the [prebuilt binaries](https://nodejs.org/en/download/))
## Run/Compile
```sh
# Compile and run
cargo run
# or just compile (binary located in target/release/bitwarden_rs)
cargo build --release
```
When run, the server is accessible in [http://localhost:80](http://localhost:80).
### Install the web-vault
Clone the git repository at [bitwarden/web](https://github.com/bitwarden/web) and checkout the latest release tag (e.g. v2.1.1):
```sh
# clone the repository
git clone https://github.com/bitwarden/web.git web-vault
cd web-vault
# switch to the latest tag
git checkout "$(git tag | tail -n1)"
```
Apply the patch file from `docker/set-vault-baseurl.patch`:
```sh
# In the Vault repository directory
git apply /path/to/bitwarden_rs/docker/set-vault-baseurl.patch
```
Then, build the Vault:
```sh
npm run sub:init
npm install
npm run dist
```
Finally copy the contents of the `build` folder into the `bitwarden_rs/web-vault` folder.
# Configuration
The available configuration options are documented in the default `.env` file, and they can be modified by uncommenting the desired options in that file or by setting their respective environment variables. Look at the README file for the main configuration options available.
Note: the environment variables override the values set in the `.env` file.
## How to recreate database schemas (for developers)
Install diesel-cli with cargo:
```sh
cargo install diesel_cli --no-default-features --features sqlite-bundled
```
Make sure that the correct path to the database is in the `.env` file.
If you want to modify the schemas, create a new migration with:
```
diesel migration generate <name>
```
Modify the *.sql files, making sure that any changes are reverted in the down.sql file.
Apply the migrations and save the generated schemas as follows:
```sh
diesel migration redo
# This step should be done automatically when using diesel-cli > 1.3.0
# diesel print-schema > src/db/schema.rs
```

2564
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,70 +2,107 @@
name = "bitwarden_rs"
version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
edition = "2018"
repository = "https://github.com/dani-garcia/bitwarden_rs"
readme = "README.md"
license = "GPL-3.0-only"
publish = false
build = "build.rs"
[features]
# Empty to keep compatibility, prefer to set USE_SYSLOG=true
enable_syslog = []
[target."cfg(not(windows))".dependencies]
syslog = "4.0.1"
[dependencies]
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
rocket = { version = "0.3.16", features = ["tls"] }
rocket_codegen = "0.3.16"
rocket_contrib = "0.3.16"
rocket = { version = "0.4.0", features = ["tls"], default-features = false }
rocket_contrib = "0.4.0"
# HTTP client
reqwest = "0.8.8"
reqwest = "0.9.15"
# multipart/form-data support
multipart = "0.15.2"
multipart = { version = "0.16.1", features = ["server"], default-features = false }
# WebSockets library
ws = "0.8.0"
# MessagePack library
rmpv = "0.4.0"
# Concurrent hashmap implementation
chashmap = "2.2.2"
# A generic serialization/deserialization framework
serde = "1.0.74"
serde_derive = "1.0.74"
serde_json = "1.0.26"
serde = "1.0.90"
serde_derive = "1.0.90"
serde_json = "1.0.39"
# Logging
log = "0.4.6"
fern = { version = "0.5.8", features = ["syslog-4"] }
# A safe, extensible ORM and Query builder
diesel = { version = "1.3.2", 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.11.0", features = ["rsa_signing"] }
ring = { version = "0.13.5", features = ["rsa_signing"] }
# UUID generation
uuid = { version = "0.6.5", features = ["v4"] }
uuid = { version = "0.7.4", features = ["v4"] }
# Date and time library for Rust
chrono = "0.4.5"
chrono = "0.4.6"
# TOTP library
oath = "0.10.2"
# Data encoding library
data-encoding = "2.1.1"
data-encoding = "2.1.2"
# JWT library
jsonwebtoken = "= 4.0.1"
jsonwebtoken = "5.0.1"
# U2F library
u2f = "0.1.2"
u2f = "0.1.4"
# Yubico Library
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 = "1.1.0"
lazy_static = "1.3.0"
# More derives
derive_more = "0.14.0"
# Numerical libraries
num-traits = "0.2.5"
num-derive = "0.2.2"
num-traits = "0.2.6"
num-derive = "0.2.5"
lettre = "0.8.2"
lettre_email = "0.8.2"
native-tls = "0.1.5"
fast_chemail = "0.9.5"
# Email libraries
lettre = "0.9.0"
lettre_email = "0.9.0"
native-tls = "0.2.2"
quoted_printable = "0.4.0"
# Template library
handlebars = "1.1.0"
# For favicon extraction from main website
soup = "0.3.0"
regex = "1.1.6"
[patch.crates-io]
# Make jwt use ring 0.11, to match rocket
jsonwebtoken = { path = "libs/jsonwebtoken" }
# Version 0.1.2 from crates.io lacks a commit that fixes a certificate error
u2f = { git = 'https://github.com/wisespace-io/u2f-rs', rev = '193de35093a44' }
# Add support for Timestamp type
rmp = { git = 'https://github.com/dani-garcia/msgpack-rust' }

View File

@@ -2,27 +2,21 @@
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE #######################
FROM node:8-alpine as vault
FROM alpine as vault
ENV VAULT_VERSION "v2.2.0"
ENV VAULT_VERSION "v2.10.0b"
ENV URL "https://github.com/bitwarden/web.git"
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 \
git \
tar
RUN git clone -b $VAULT_VERSION --depth 1 $URL web-build
WORKDIR /web-build
RUN mkdir /web-vault
WORKDIR /web-vault
COPY /docker/set-vault-baseurl.patch /web-build/
RUN git apply set-vault-baseurl.patch
RUN npm run sub:init && npm install
RUN npm run dist \
&& mv build /web-vault
RUN curl -L $URL | tar xz
RUN ls
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -39,10 +33,10 @@ FROM rust as build
RUN USER=root cargo new --bin app
WORKDIR /app
# Copies over *only* your manifests and vendored dependencies
# Copies over *only* your manifests and build files
COPY ./Cargo.* ./
COPY ./libs ./libs
COPY ./rust-toolchain ./rust-toolchain
COPY ./build.rs ./build.rs
# Builds your dependencies and removes the
# dummy project, except the target folder
@@ -54,6 +48,9 @@ RUN find . -not -path "./target*" -delete
# To avoid copying unneeded files, use .dockerignore
COPY . .
# Make sure that we actually build the project
RUN touch src/main.rs
# Builds again, this time it'll just be
# your actual source files being built
RUN cargo build --release
@@ -64,6 +61,7 @@ RUN cargo build --release
FROM debian:stretch-slim
ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10
# Install needed libraries
@@ -76,10 +74,10 @@ RUN apt-get update && apt-get install -y\
RUN mkdir /data
VOLUME /data
EXPOSE 80
EXPOSE 3012
# Copies the files from the context (env file and web-vault)
# Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage
COPY .env .
COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build app/target/release/bitwarden_rs .

92
Dockerfile.aarch64 Normal file
View File

@@ -0,0 +1,92 @@
# 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.10.0b"
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-aarch64-linux-gnu \
&& mkdir -p ~/.cargo \
&& echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config \
&& echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
WORKDIR /app
# Prepare openssl arm64 libs
RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
/etc/apt/sources.list.d/deb-src.list \
&& dpkg --add-architecture arm64 \
&& apt-get update \
&& apt-get install -y \
libssl-dev:arm64 \
libc6-dev:arm64
ENV CC_aarch64_unknown_linux_gnu="/usr/bin/aarch64-linux-gnu-gcc"
ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/aarch64-linux-gnu"
ENV OPENSSL_LIB_DIR="/usr/lib/aarch64-linux-gnu"
# Copies the complete project
# To avoid copying unneeded files, use .dockerignore
COPY . .
# Build
RUN rustup target add aarch64-unknown-linux-gnu
RUN cargo build --release --target=aarch64-unknown-linux-gnu -v
######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image
# because we already have a binary built
FROM balenalib/aarch64-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\
&& 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/aarch64-unknown-linux-gnu/release/bitwarden_rs .
# Configures the startup!
CMD ./bitwarden_rs

View File

@@ -2,52 +2,37 @@
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE #######################
FROM node:8-alpine as vault
FROM alpine as vault
ENV VAULT_VERSION "v2.2.0"
ENV VAULT_VERSION "v2.10.0b"
ENV URL "https://github.com/bitwarden/web.git"
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 \
git \
tar
RUN git clone -b $VAULT_VERSION --depth 1 $URL web-build
WORKDIR /web-build
RUN mkdir /web-vault
WORKDIR /web-vault
COPY /docker/set-vault-baseurl.patch /web-build/
RUN git apply set-vault-baseurl.patch
RUN npm run sub:init && npm install
RUN npm run dist \
&& mv build /web-vault
RUN curl -L $URL | tar xz
RUN ls
########################## BUILD IMAGE ##########################
# Musl build image for statically compiled binary
FROM clux/muslrust:nightly-2018-08-24 as build
FROM clux/muslrust:nightly-2018-12-01 as build
# Creates a dummy project used to grab dependencies
RUN USER=root cargo init --bin
ENV USER "root"
# Copies over *only* your manifests and vendored dependencies
COPY ./Cargo.* ./
COPY ./libs ./libs
COPY ./rust-toolchain ./rust-toolchain
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN cargo build --release
RUN find . -not -path "./target*" -delete
WORKDIR /app
# Copies the complete project
# To avoid copying unneeded files, use .dockerignore
COPY . .
# Builds again, this time it'll just be
# your actual source files being built
RUN rustup target add x86_64-unknown-linux-musl
# Build
RUN cargo build --release
######################## RUNTIME IMAGE ########################
@@ -56,6 +41,7 @@ RUN cargo build --release
FROM alpine:3.8
ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10
ENV SSL_CERT_DIR=/etc/ssl/certs
@@ -68,13 +54,13 @@ RUN apk add \
RUN mkdir /data
VOLUME /data
EXPOSE 80
EXPOSE 3012
# Copies the files from the context (env file and web-vault)
# Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage
COPY .env .
COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /volume/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
# Configures the startup!
CMD ./bitwarden_rs

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.10.0b"
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

@@ -2,27 +2,21 @@
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE #######################
FROM node:8-alpine as vault
FROM alpine as vault
ENV VAULT_VERSION "v2.2.0"
ENV VAULT_VERSION "v2.10.0b"
ENV URL "https://github.com/bitwarden/web.git"
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 \
git \
tar
RUN git clone -b $VAULT_VERSION --depth 1 $URL web-build
WORKDIR /web-build
RUN mkdir /web-vault
WORKDIR /web-vault
COPY /docker/set-vault-baseurl.patch /web-build/
RUN git apply set-vault-baseurl.patch
RUN npm run sub:init && npm install
RUN npm run dist \
&& mv build /web-vault
RUN curl -L $URL | tar xz
RUN ls
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -39,15 +33,8 @@ RUN apt-get update \
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
# Creates a dummy project used to grab dependencies
RUN USER=root cargo new --bin app
WORKDIR /app
# Copies over *only* your manifests and vendored dependencies
COPY ./Cargo.* ./
COPY ./libs ./libs
COPY ./rust-toolchain ./rust-toolchain
# Prepare openssl armhf libs
RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
/etc/apt/sources.list.d/deb-src.list \
@@ -62,28 +49,21 @@ ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabihf"
ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
COPY . .
RUN rustup target add armv7-unknown-linux-gnueabihf
RUN cargo build --release --target=armv7-unknown-linux-gnueabihf -v
RUN find . -not -path "./target*" -delete
# Copies the complete project
# To avoid copying unneeded files, use .dockerignore
COPY . .
# Builds again, this time it'll just be
# your actual source files being built
# Build
RUN rustup target add armv7-unknown-linux-gnueabihf
RUN cargo build --release --target=armv7-unknown-linux-gnueabihf -v
######################## RUNTIME IMAGE ########################
# Create a new stage with a minimal image
# because we already have a binary built
FROM resin/armv7hf-debian:stretch
FROM balenalib/armv7hf-debian:stretch
ENV ROCKET_ENV "staging"
ENV ROCKET_PORT=80
ENV ROCKET_WORKERS=10
RUN [ "cross-build-start" ]
@@ -102,9 +82,8 @@ RUN [ "cross-build-end" ]
VOLUME /data
EXPOSE 80
# Copies the files from the context (env file and web-vault)
# Copies the files from the context (Rocket.toml file and web-vault)
# and the binary from the "build" stage to the current stage
COPY .env .
COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .

403
README.md
View File

@@ -3,57 +3,20 @@
---
[![Travis Build Status](https://travis-ci.org/dani-garcia/bitwarden_rs.svg?branch=master)](https://travis-ci.org/dani-garcia/bitwarden_rs)
[![Docker Pulls](https://img.shields.io/docker/pulls/mprasil/bitwarden.svg)](https://hub.docker.com/r/mprasil/bitwarden)
[![Dependency Status](https://deps.rs/repo/github/dani-garcia/bitwarden_rs/status.svg)](https://deps.rs/repo/github/dani-garcia/bitwarden_rs)
[![GitHub Release](https://img.shields.io/github/release/dani-garcia/bitwarden_rs.svg)](https://github.com/dani-garcia/bitwarden_rs/releases/latest)
[![GPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/bitwarden_rs.svg)](https://github.com/dani-garcia/bitwarden_rs/blob/master/LICENSE.txt)
[![Matrix Chat](https://matrix.to/img/matrix-badge.svg)](https://matrix.to/#/#bitwarden_rs:matrix.org)
[![Matrix Chat](https://img.shields.io/matrix/bitwarden_rs:matrix.org.svg?logo=matrix)](https://matrix.to/#/#bitwarden_rs:matrix.org)
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/bitwarden_rs).
_*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.
---
**Table of contents**
- [Features](#features)
- [Missing features](#missing-features)
- [Docker image usage](#docker-image-usage)
- [Starting a container](#starting-a-container)
- [Updating the bitwarden image](#updating-the-bitwarden-image)
- [Configuring bitwarden service](#configuring-bitwarden-service)
- [Disable registration of new users](#disable-registration-of-new-users)
- [Disable invitations](#disable-invitations)
- [Enabling HTTPS](#enabling-https)
- [Enabling U2F authentication](#enabling-u2f-authentication)
- [Changing persistent data location](#changing-persistent-data-location)
- [/data prefix:](#data-prefix)
- [database name and location](#database-name-and-location)
- [attachments location](#attachments-location)
- [icons cache](#icons-cache)
- [Changing the API request size limit](#changing-the-api-request-size-limit)
- [Changing the number of workers](#changing-the-number-of-workers)
- [SMTP configuration](#smtp-configuration)
- [Password hint display](#password-hint-display)
- [Disabling or overriding the Vault interface hosting](#disabling-or-overriding-the-vault-interface-hosting)
- [Other configuration](#other-configuration)
- [Building your own image](#building-your-own-image)
- [Building binary](#building-binary)
- [Available packages](#available-packages)
- [Arch Linux](#arch-linux)
- [Backing up your vault](#backing-up-your-vault)
- [1. the sqlite3 database](#1-the-sqlite3-database)
- [2. the attachments folder](#2-the-attachments-folder)
- [3. the key files](#3-the-key-files)
- [4. Icon Cache](#4-icon-cache)
- [Running the server with non-root user](#running-the-server-with-non-root-user)
- [Differences from upstream API implementation](#differences-from-upstream-api-implementation)
- [Changing user email](#changing-user-email)
- [Creating organization](#creating-organization)
- [Inviting users into organization](#inviting-users-into-organization)
- [Running on unencrypted connection](#running-on-unencrypted-connection)
- [Get in touch](#get-in-touch)
## Features
Basically full implementation of Bitwarden API is provided including:
@@ -65,359 +28,25 @@ Basically full implementation of Bitwarden API is provided including:
* Serving the static files for Vault interface
* Website icons API
* Authenticator and U2F support
* YubiKey OTP
## Missing features
* Email confirmation
* Other two-factor systems:
* YubiKey OTP (if your key supports U2F, you can use that)
* Duo
* Email codes
## Installation
Pull the docker image and mount a volume from the host for persistent storage:
## Docker image usage
### Starting a container
The persistent data is stored under /data inside the container, so the only requirement for persistent deployment using Docker is to mount persistent volume at the path:
```
```sh
docker pull mprasil/bitwarden:latest
docker run -d --name bitwarden -v /bw-data/:/data/ -p 80:80 mprasil/bitwarden:latest
```
This will preserve any persistent data under /bw-data/, you can adapt the path to whatever suits you.
This will preserve any persistent data under `/bw-data/`, you can adapt the path to whatever suits you.
**IMPORTANT**: Some web browsers, like Chrome, disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault from HTTPS.
The service will be exposed on port 80.
This can be configured in [bitwarden_rs directly](https://github.com/dani-garcia/bitwarden_rs/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/bitwarden_rs/wiki/Proxy-examples)).
### Updating the bitwarden image
If you have an available domain name, you can get HTTPS certificates with [Let's Encrypt](https://letsencrypt.org/), or you can generate self-signed certificates with utilities like [mkcert](https://github.com/FiloSottile/mkcert). Some proxies automatically do this step, like Caddy (see examples linked above).
Updating is straightforward, you just make sure to preserve the mounted volume. If you used the bind-mounted path as in the example above, you just need to `pull` the latest image, `stop` and `rm` the current container and then start a new one the same way as before:
```sh
# Pull the latest version
docker pull mprasil/bitwarden:latest
# Stop and remove the old container
docker stop bitwarden
docker rm bitwarden
# Start new container with the data mounted
docker run -d --name bitwarden -v /bw-data/:/data/ -p 80:80 mprasil/bitwarden:latest
```
Then visit [http://localhost:80](http://localhost:80)
In case you didn't bind mount the volume for persistent data, you need an intermediate step where you preserve the data with an intermediate container:
```sh
# Pull the latest version
docker pull mprasil/bitwarden:latest
# Create intermediate container to preserve data
docker run --volumes-from bitwarden --name bitwarden_data busybox true
# Stop and remove the old container
docker stop bitwarden
docker rm bitwarden
# Start new container with the data mounted
docker run -d --volumes-from bitwarden_data --name bitwarden -p 80:80 mprasil/bitwarden:latest
# Optionally remove the intermediate container
docker rm bitwarden_data
# Alternatively you can keep data container around for future updates in which case you can skip last step.
```
## Configuring bitwarden service
### Disable registration of new users
By default new users can register, if you want to disable that, set the `SIGNUPS_ALLOWED` env variable to `false`:
```sh
docker run -d --name bitwarden \
-e SIGNUPS_ALLOWED=false \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note: While users can't register on their own, they can still be invited by already registered users. Read bellow if you also want to disable that.
### Disable invitations
Even when registration is disabled, organization administrators or owners can invite users to join organization. This won't send email invitation to the users, but after they are invited, they can register with the invited email even if `SIGNUPS_ALLOWED` is actually set to `false`. You can disable this functionality completely by setting `INVITATIONS_ALLOWED` env variable to `false`:
```sh
docker run -d --name bitwarden \
-e SIGNUPS_ALLOWED=false \
-e INVITATIONS_ALLOWED=false \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
### Enabling HTTPS
To enable HTTPS, you need to configure the `ROCKET_TLS`.
The values to the option must follow the format:
```
ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
```
Where:
- certs: a path to a certificate chain in PEM format
- key: a path to a private key file in PEM format for the certificate in certs
```sh
docker run -d --name bitwarden \
-e ROCKET_TLS='{certs="/ssl/certs.pem",key="/ssl/key.pem"}' \
-v /ssl/keys/:/ssl/ \
-v /bw-data/:/data/ \
-p 443:80 \
mprasil/bitwarden:latest
```
Note that you need to mount ssl files and you need to forward appropriate port.
### Enabling U2F authentication
To enable U2F authentication, you must be serving bitwarden_rs from an HTTPS domain with a valid certificate (Either using the included
HTTPS options or with a reverse proxy). We recommend using a free certificate from Let's Encrypt.
After that, you need to set the `DOMAIN` environment variable to the same address from where bitwarden_rs is being served:
```sh
docker run -d --name bitwarden \
-e DOMAIN=https://bw.domain.tld \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note that the value has to include the `https://` and it may include a port at the end (in the format of `https://bw.domain.tld:port`) when not using `443`.
### Changing persistent data location
#### /data prefix:
By default all persistent data is saved under `/data`, you can override this path by setting the `DATA_FOLDER` env variable:
```sh
docker run -d --name bitwarden \
-e DATA_FOLDER=/persistent \
-v /bw-data/:/persistent/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Notice, that you need to adapt your volume mount accordingly.
#### database name and location
Default is `$DATA_FOLDER/db.sqlite3`, you can change the path specifically for database using `DATABASE_URL` variable:
```sh
docker run -d --name bitwarden \
-e DATABASE_URL=/database/bitwarden.sqlite3 \
-v /bw-data/:/data/ \
-v /bw-database/:/database/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note, that you need to remember to mount the volume for both database and other persistent data if they are different.
#### attachments location
Default is `$DATA_FOLDER/attachments`, you can change the path using `ATTACHMENTS_FOLDER` variable:
```sh
docker run -d --name bitwarden \
-e ATTACHMENTS_FOLDER=/attachments \
-v /bw-data/:/data/ \
-v /bw-attachments/:/attachments/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note, that you need to remember to mount the volume for both attachments and other persistent data if they are different.
#### icons cache
Default is `$DATA_FOLDER/icon_cache`, you can change the path using `ICON_CACHE_FOLDER` variable:
```sh
docker run -d --name bitwarden \
-e ICON_CACHE_FOLDER=/icon_cache \
-v /bw-data/:/data/ \
-v /icon_cache/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note, that in the above example we don't mount the volume locally, which means it won't be persisted during the upgrade unless you use intermediate data container using `--volumes-from`. This will impact performance as bitwarden will have to re-download the icons on restart, but might save you from having stale icons in cache as they are not automatically cleaned.
### Changing the API request size limit
By default the API calls are limited to 10MB. This should be sufficient for most cases, however if you want to support large imports, this might be limiting you. On the other hand you might want to limit the request size to something smaller than that to prevent API abuse and possible DOS attack, especially if running with limited resources.
To set the limit, you can use the `ROCKET_LIMITS` variable. Example here shows 10MB limit for posted json in the body (this is the default):
```sh
docker run -d --name bitwarden \
-e ROCKET_LIMITS={json=10485760} \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
### Changing the number of workers
When you run bitwarden_rs, it spawns `2 * <number of cpu cores>` workers to handle requests. On some systems this might lead to low number of workers and hence slow performance, so the default in the docker image is changed to spawn 10 threads. You can override this setting to increase or decrease the number of workers by setting the `ROCKET_WORKERS` variable.
In the example bellow, we're starting with 20 workers:
```sh
docker run -d --name bitwarden \
-e ROCKET_WORKERS=20 \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
### SMTP configuration
You can configure bitwarden_rs to send emails via a SMTP agent:
```sh
docker run -d --name bitwarden \
-e SMTP_HOST=<smtp.domain.tld> \
-e SMTP_PORT=587 \
-e SMTP_SSL=true \
-e SMTP_USERNAME=<username> \
-e SMTP_PASSWORD=<password> \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
When `SMTP_SSL` is set to `true`(this is the default), only TLSv1.1 and TLSv1.2 protocols will be accepted and `SMTP_PORT` will default to `587`. If set to `false`, `SMTP_PORT` will default to `25` and the connection won't be encrypted. This can be very insecure, use this setting only if you know what you're doing.
### Password hint display
Usually, password hints are sent by email. But as bitwarden_rs is made with small or personal deployment in mind, hints are also available from the password hint page, so you don't have to configure an email service. If you want to disable this feature, you can use the `SHOW_PASSWORD_HINT` variable:
```sh
docker run -d --name bitwarden \
-e SHOW_PASSWORD_HINT=false \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
### Disabling or overriding the Vault interface hosting
As a convenience bitwarden_rs image will also host static files for Vault web interface. You can disable this static file hosting completely by setting the WEB_VAULT_ENABLED variable.
```sh
docker run -d --name bitwarden \
-e WEB_VAULT_ENABLED=false \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Alternatively you can override the Vault files and provide your own static files to host. You can do that by mounting a path with your files over the `/web-vault` directory in the container. Just make sure the directory contains at least `index.html` file.
```sh
docker run -d --name bitwarden \
-v /path/to/static/files_directory:/web-vault \
-v /bw-data/:/data/ \
-p 80:80 \
mprasil/bitwarden:latest
```
Note that you can also change the path where bitwarden_rs looks for static files by providing the `WEB_VAULT_FOLDER` environment variable with the path.
### Other configuration
Though this is unlikely to be required in small deployment, you can fine-tune some other settings like number of workers using environment variables that are processed by [Rocket](https://rocket.rs), please see details in [documentation](https://rocket.rs/guide/configuration/#environment-variables).
## Building your own image
Clone the repository, then from the root of the repository run:
```sh
# Build the docker image:
docker build -t bitwarden_rs .
```
## Building binary
For building binary outside the Docker environment and running it locally without docker, please see [build instructions](BUILD.md).
## Available packages
### Arch Linux
Bitwarden_rs is already packaged for Archlinux thanks to @mqus. There is an [AUR package](https://aur.archlinux.org/packages/bitwarden_rs) (optionally with the [vault web interface](https://aur.archlinux.org/packages/bitwarden_rs-vault/) ) available.
## Backing up your vault
### 1. the sqlite3 database
The sqlite3 database should be backed up using the proper sqlite3 backup command. This will ensure the database does not become corrupted if the backup happens during a database write.
```
sqlite3 /$DATA_FOLDER/db.sqlite3 ".backup '/$DATA_FOLDER/db-backup/backup.sq3'"
```
This command can be run via a CRON job everyday, however note that it will overwrite the same backup.sq3 file each time. This backup file should therefore be saved via incremental backup either using a CRON job command that appends a timestamp or from another backup app such as Duplicati.
### 2. the attachments folder
By default, this is located in `$DATA_FOLDER/attachments`
### 3. the key files
This is optional, these are only used to store tokens of users currently logged in, deleting them would simply log each user out forcing them to log in again. By default, these are located in the `$DATA_FOLDER` (by default /data in the docker). There are 3 files: rsa_key.der, rsa_key.pem, rsa_key.pub.der.
### 4. Icon Cache
This is optional, the icon cache can re-download itself however if you have a large cache, it may take a long time. By default it is located in `$DATA_FOLDER/icon_cache`
## Running the server with non-root user
The root user inside the container is already pretty limited in what it can do, so the default setup should be secure enough. However if you wish to go the extra mile to avoid using root even in container, here's how you can do that:
1. Create a data folder that's owned by non-root user, so you can use that user to write persistent data. Get the user `id`. In linux you can run `stat <folder_name>` to get/verify the owner ID.
2. When you run the container, you need to provide the user ID as one of the parameters. Note that this needs to be in the numeric form and not the user name, because docker would try to find such user defined inside the image, which would likely not be there or it would have different ID than your local user and hence wouldn't be able to write the persistent data. This can be done with the `--user` parameter.
3. bitwarden_rs listens on port `80` inside the container by default, this [won't work with non-root user](https://www.w3.org/Daemon/User/Installation/PrivilegedPorts.html), because regular users aren't allowed to open port bellow `1024`. To overcome this, you need to configure server to listen on a different port, you can use `ROCKET_PORT` to do that.
Here's sample docker run, that uses user with id `1000` and with the port redirection configured, so that inside container the service is listening on port `8080` and docker translates that to external (host) port `80`:
```sh
docker run -d --name bitwarden \
--user 1000 \
-e ROCKET_PORT=8080 \
-v /bw-data/:/data/ \
-p 80:8080 \
mprasil/bitwarden:latest
```
## Differences from upstream API implementation
### Changing user email
Because we don't have any SMTP functionality at the moment, there's no way to deliver the verification token when you try to change the email. User just needs to enter any random token to continue and the change will be applied.
### Creating organization
We use upstream Vault interface directly without any (significant) changes, this is why user is presented with paid options when creating organization. To create an organization, just use the free option, none of the limits apply when using bitwarden_rs as back-end API and after the organization is created it should behave like Enterprise organization.
### Inviting users into organization
If you have [invitations disabled](#disable-invitations), the users must already be registered on your server to invite them. The invited users won't get the invitation email, instead they will appear in the interface as if they already accepted the invitation. (if the user has already registered) Organization admin then just needs to confirm them to be proper Organization members and to give them access to the shared secrets.
### Running on unencrypted connection
It is strongly recommended to run bitwarden_rs service over HTTPS. However the server itself while [supporting it](#enabling-https) does not strictly require such setup. This makes it a bit easier to spin up the service in cases where you can generally trust the connection (internal and secure network, access over VPN,..) or when you want to put the service behind HTTP proxy, that will do the encryption on the proxy end.
Running over HTTP is still reasonably secure provided you use really strong master password and that you avoid using web Vault over connection that is vulnerable to MITM attacks where attacker could inject javascript into your interface. However some forms of 2FA might not work in this setup and [Vault doesn't work in this configuration in Chrome](https://github.com/bitwarden/web/issues/254).
## Usage
See the [bitwarden_rs wiki](https://github.com/dani-garcia/bitwarden_rs/wiki) for more information on how to configure and run the bitwarden_rs server.
## Get in touch

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'

57
build.rs Normal file
View File

@@ -0,0 +1,57 @@
use std::process::Command;
fn main() {
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())
}
/// This method reads info from Git, namely tags, branch, and revision
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"]).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
let last_tag = run(&["git", "describe", "--abbrev=0", "--tags"])?;
println!("cargo:rustc-env=GIT_LAST_TAG={}", last_tag);
// The current branch name
let branch = run(&["git", "rev-parse", "--abbrev-ref", "HEAD"])?;
println!("cargo:rustc-env=GIT_BRANCH={}", branch);
// The current git commit hash
let rev = run(&["git", "rev-parse", "HEAD"])?;
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,28 +0,0 @@
--- a/src/app/services/services.module.ts
+++ b/src/app/services/services.module.ts
@@ -120,20 +120,17 @@ const notificationsService = new NotificationsService(userService, syncService,
const environmentService = new EnvironmentService(apiService, storageService, notificationsService);
const auditService = new AuditService(cryptoFunctionService, apiService);
-const analytics = new Analytics(window, () => platformUtilsService.isDev() || platformUtilsService.isSelfHost(),
+const analytics = new Analytics(window, () => platformUtilsService.isDev() || platformUtilsService.isSelfHost() || true,
platformUtilsService, storageService, appIdService);
containerService.attachToWindow(window);
export function initFactory(): Function {
return async () => {
await (storageService as HtmlStorageService).init();
- const isDev = platformUtilsService.isDev();
- if (!isDev && platformUtilsService.isSelfHost()) {
- environmentService.baseUrl = window.location.origin;
- } else {
- environmentService.notificationsUrl = isDev ? 'http://localhost:61840' :
- 'https://notifications.bitwarden.com'; // window.location.origin + '/notifications';
- }
+ const isDev = false;
+ environmentService.baseUrl = window.location.origin;
+ environmentService.notificationsUrl = window.location.origin + '/notifications';
+
await apiService.setUrls({
base: isDev ? null : window.location.origin,
api: isDev ? 'http://localhost:4000' : null,

View File

@@ -1,20 +0,0 @@
[package]
name = "jsonwebtoken"
version = "4.0.1"
authors = ["Vincent Prouillet <prouillet.vincent@gmail.com>"]
license = "MIT"
readme = "README.md"
description = "Create and parse JWT in a strongly typed way."
homepage = "https://github.com/Keats/rust-jwt"
repository = "https://github.com/Keats/rust-jwt"
keywords = ["jwt", "web", "api", "token", "json"]
[dependencies]
error-chain = { version = "0.11", default-features = false }
serde_json = "1.0"
serde_derive = "1.0"
serde = "1.0"
ring = { version = "0.11.0", features = ["rsa_signing", "dev_urandom_fallback"] }
base64 = "0.9"
untrusted = "0.5"
chrono = "0.4"

View File

@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015 Vincent Prouillet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,120 +0,0 @@
use std::sync::Arc;
use base64;
use ring::{rand, digest, hmac, signature};
use ring::constant_time::verify_slices_are_equal;
use untrusted;
use errors::{Result, ErrorKind};
/// The algorithms supported for signing/verifying
#[derive(Debug, PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum Algorithm {
/// HMAC using SHA-256
HS256,
/// HMAC using SHA-384
HS384,
/// HMAC using SHA-512
HS512,
/// RSASSA-PKCS1-v1_5 using SHA-256
RS256,
/// RSASSA-PKCS1-v1_5 using SHA-384
RS384,
/// RSASSA-PKCS1-v1_5 using SHA-512
RS512,
}
/// The actual HS signing + encoding
fn sign_hmac(alg: &'static digest::Algorithm, key: &[u8], signing_input: &str) -> Result<String> {
let signing_key = hmac::SigningKey::new(alg, key);
let digest = hmac::sign(&signing_key, signing_input.as_bytes());
Ok(
base64::encode_config::<hmac::Signature>(&digest, base64::URL_SAFE_NO_PAD)
)
}
/// The actual RSA signing + encoding
/// Taken from Ring doc https://briansmith.org/rustdoc/ring/signature/index.html
fn sign_rsa(alg: Algorithm, key: &[u8], signing_input: &str) -> Result<String> {
let ring_alg = match alg {
Algorithm::RS256 => &signature::RSA_PKCS1_SHA256,
Algorithm::RS384 => &signature::RSA_PKCS1_SHA384,
Algorithm::RS512 => &signature::RSA_PKCS1_SHA512,
_ => unreachable!(),
};
let key_pair = Arc::new(
signature::RSAKeyPair::from_der(untrusted::Input::from(key))
.map_err(|_| ErrorKind::InvalidKey)?
);
let mut signing_state = signature::RSASigningState::new(key_pair)
.map_err(|_| ErrorKind::InvalidKey)?;
let mut signature = vec![0; signing_state.key_pair().public_modulus_len()];
let rng = rand::SystemRandom::new();
signing_state.sign(ring_alg, &rng, signing_input.as_bytes(), &mut signature)
.map_err(|_| ErrorKind::InvalidKey)?;
Ok(
base64::encode_config::<[u8]>(&signature, base64::URL_SAFE_NO_PAD)
)
}
/// Take the payload of a JWT, sign it using the algorithm given and return
/// the base64 url safe encoded of the result.
///
/// Only use this function if you want to do something other than JWT.
pub fn sign(signing_input: &str, key: &[u8], algorithm: Algorithm) -> Result<String> {
match algorithm {
Algorithm::HS256 => sign_hmac(&digest::SHA256, key, signing_input),
Algorithm::HS384 => sign_hmac(&digest::SHA384, key, signing_input),
Algorithm::HS512 => sign_hmac(&digest::SHA512, key, signing_input),
Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 => sign_rsa(algorithm, key, signing_input),
// TODO: if PKCS1 is made prublic, remove the line above and uncomment below
// Algorithm::RS256 => sign_rsa(&signature::RSA_PKCS1_SHA256, key, signing_input),
// Algorithm::RS384 => sign_rsa(&signature::RSA_PKCS1_SHA384, key, signing_input),
// Algorithm::RS512 => sign_rsa(&signature::RSA_PKCS1_SHA512, key, signing_input),
}
}
/// See Ring RSA docs for more details
fn verify_rsa(alg: &signature::RSAParameters, signature: &str, signing_input: &str, key: &[u8]) -> Result<bool> {
let signature_bytes = base64::decode_config(signature, base64::URL_SAFE_NO_PAD)?;
let public_key_der = untrusted::Input::from(key);
let message = untrusted::Input::from(signing_input.as_bytes());
let expected_signature = untrusted::Input::from(signature_bytes.as_slice());
let res = signature::verify(alg, public_key_der, message, expected_signature);
Ok(res.is_ok())
}
/// Compares the signature given with a re-computed signature for HMAC or using the public key
/// for RSA.
///
/// Only use this function if you want to do something other than JWT.
///
/// `signature` is the signature part of a jwt (text after the second '.')
///
/// `signing_input` is base64(header) + "." + base64(claims)
pub fn verify(signature: &str, signing_input: &str, key: &[u8], algorithm: Algorithm) -> Result<bool> {
match algorithm {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
// we just re-sign the data with the key and compare if they are equal
let signed = sign(signing_input, key, algorithm)?;
Ok(verify_slices_are_equal(signature.as_ref(), signed.as_ref()).is_ok())
},
Algorithm::RS256 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA256, signature, signing_input, key),
Algorithm::RS384 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA384, signature, signing_input, key),
Algorithm::RS512 => verify_rsa(&signature::RSA_PKCS1_2048_8192_SHA512, signature, signing_input, key),
}
}
impl Default for Algorithm {
fn default() -> Self {
Algorithm::HS256
}
}

View File

@@ -1,68 +0,0 @@
use base64;
use serde_json;
use ring;
error_chain! {
errors {
/// When a token doesn't have a valid JWT shape
InvalidToken {
description("invalid token")
display("Invalid token")
}
/// When the signature doesn't match
InvalidSignature {
description("invalid signature")
display("Invalid signature")
}
/// When the secret given is not a valid RSA key
InvalidKey {
description("invalid key")
display("Invalid Key")
}
// Validation error
/// When a tokens `exp` claim indicates that it has expired
ExpiredSignature {
description("expired signature")
display("Expired Signature")
}
/// When a tokens `iss` claim does not match the expected issuer
InvalidIssuer {
description("invalid issuer")
display("Invalid Issuer")
}
/// When a tokens `aud` claim does not match one of the expected audience values
InvalidAudience {
description("invalid audience")
display("Invalid Audience")
}
/// When a tokens `aud` claim does not match one of the expected audience values
InvalidSubject {
description("invalid subject")
display("Invalid Subject")
}
/// When a tokens `iat` claim is in the future
InvalidIssuedAt {
description("invalid issued at")
display("Invalid Issued At")
}
/// When a tokens nbf claim represents a time in the future
ImmatureSignature {
description("immature signature")
display("Immature Signature")
}
/// When the algorithm in the header doesn't match the one passed to `decode`
InvalidAlgorithm {
description("Invalid algorithm")
display("Invalid Algorithm")
}
}
foreign_links {
Unspecified(ring::error::Unspecified) #[doc = "An error happened while signing/verifying a token with RSA"];
Base64(base64::DecodeError) #[doc = "An error happened while decoding some base64 text"];
Json(serde_json::Error) #[doc = "An error happened while serializing/deserializing JSON"];
Utf8(::std::string::FromUtf8Error) #[doc = "An error happened while trying to convert the result of base64 decoding to a String"];
}
}

View File

@@ -1,64 +0,0 @@
use crypto::Algorithm;
/// A basic JWT header, the alg defaults to HS256 and typ is automatically
/// set to `JWT`. All the other fields are optional.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Header {
/// The type of JWS: it can only be "JWT" here
///
/// Defined in [RFC7515#4.1.9](https://tools.ietf.org/html/rfc7515#section-4.1.9).
#[serde(skip_serializing_if = "Option::is_none")]
pub typ: Option<String>,
/// The algorithm used
///
/// Defined in [RFC7515#4.1.1](https://tools.ietf.org/html/rfc7515#section-4.1.1).
pub alg: Algorithm,
/// Content type
///
/// Defined in [RFC7519#5.2](https://tools.ietf.org/html/rfc7519#section-5.2).
#[serde(skip_serializing_if = "Option::is_none")]
pub cty: Option<String>,
/// JSON Key URL
///
/// Defined in [RFC7515#4.1.2](https://tools.ietf.org/html/rfc7515#section-4.1.2).
#[serde(skip_serializing_if = "Option::is_none")]
pub jku: Option<String>,
/// Key ID
///
/// Defined in [RFC7515#4.1.4](https://tools.ietf.org/html/rfc7515#section-4.1.4).
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
/// X.509 URL
///
/// Defined in [RFC7515#4.1.5](https://tools.ietf.org/html/rfc7515#section-4.1.5).
#[serde(skip_serializing_if = "Option::is_none")]
pub x5u: Option<String>,
/// X.509 certificate thumbprint
///
/// Defined in [RFC7515#4.1.7](https://tools.ietf.org/html/rfc7515#section-4.1.7).
#[serde(skip_serializing_if = "Option::is_none")]
pub x5t: Option<String>,
}
impl Header {
/// Returns a JWT header with the algorithm given
pub fn new(algorithm: Algorithm) -> Header {
Header {
typ: Some("JWT".to_string()),
alg: algorithm,
cty: None,
jku: None,
kid: None,
x5u: None,
x5t: None,
}
}
}
impl Default for Header {
/// Returns a JWT header using the default Algorithm, HS256
fn default() -> Self {
Header::new(Algorithm::default())
}
}

View File

@@ -1,142 +0,0 @@
//! Create and parses JWT (JSON Web Tokens)
//!
//! Documentation: [stable](https://docs.rs/jsonwebtoken/)
#![recursion_limit = "300"]
#![deny(missing_docs)]
#![allow(unused_doc_comments)]
#![allow(renamed_and_removed_lints)]
#[macro_use]
extern crate error_chain;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
extern crate serde;
extern crate base64;
extern crate ring;
extern crate untrusted;
extern crate chrono;
/// All the errors, generated using error-chain
pub mod errors;
mod header;
mod crypto;
mod serialization;
mod validation;
pub use header::Header;
pub use crypto::{
Algorithm,
sign,
verify,
};
pub use validation::Validation;
pub use serialization::TokenData;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use errors::{Result, ErrorKind};
use serialization::{from_jwt_part, from_jwt_part_claims, to_jwt_part};
use validation::{validate};
/// Encode the header and claims given and sign the payload using the algorithm from the header and the key
///
/// ```rust,ignore
/// #[macro_use]
/// extern crate serde_derive;
/// use jsonwebtoken::{encode, Algorithm, Header};
///
/// /// #[derive(Debug, Serialize, Deserialize)]
/// struct Claims {
/// sub: String,
/// company: String
/// }
///
/// let my_claims = Claims {
/// sub: "b@b.com".to_owned(),
/// company: "ACME".to_owned()
/// };
///
/// // my_claims is a struct that implements Serialize
/// // This will create a JWT using HS256 as algorithm
/// let token = encode(&Header::default(), &my_claims, "secret".as_ref()).unwrap();
/// ```
pub fn encode<T: Serialize>(header: &Header, claims: &T, key: &[u8]) -> Result<String> {
let encoded_header = to_jwt_part(&header)?;
let encoded_claims = to_jwt_part(&claims)?;
let signing_input = [encoded_header.as_ref(), encoded_claims.as_ref()].join(".");
let signature = sign(&*signing_input, key.as_ref(), header.alg)?;
Ok([signing_input, signature].join("."))
}
/// Used in decode: takes the result of a rsplit and ensure we only get 2 parts
/// Errors if we don't
macro_rules! expect_two {
($iter:expr) => {{
let mut i = $iter;
match (i.next(), i.next(), i.next()) {
(Some(first), Some(second), None) => (first, second),
_ => return Err(ErrorKind::InvalidToken.into())
}
}}
}
/// Decode a token into a struct containing 2 fields: `claims` and `header`.
///
/// If the token or its signature is invalid or the claims fail validation, it will return an error.
///
/// ```rust,ignore
/// #[macro_use]
/// extern crate serde_derive;
/// use jsonwebtoken::{decode, Validation, Algorithm};
///
/// #[derive(Debug, Serialize, Deserialize)]
/// struct Claims {
/// sub: String,
/// company: String
/// }
///
/// let token = "a.jwt.token".to_string();
/// // Claims is a struct that implements Deserialize
/// let token_data = decode::<Claims>(&token, "secret", &Validation::new(Algorithm::HS256));
/// ```
pub fn decode<T: DeserializeOwned>(token: &str, key: &[u8], validation: &Validation) -> Result<TokenData<T>> {
let (signature, signing_input) = expect_two!(token.rsplitn(2, '.'));
let (claims, header) = expect_two!(signing_input.rsplitn(2, '.'));
let header: Header = from_jwt_part(header)?;
if !verify(signature, signing_input, key, header.alg)? {
return Err(ErrorKind::InvalidSignature.into());
}
if !validation.algorithms.contains(&header.alg) {
return Err(ErrorKind::InvalidAlgorithm.into());
}
let (decoded_claims, claims_map): (T, _) = from_jwt_part_claims(claims)?;
validate(&claims_map, validation)?;
Ok(TokenData { header: header, claims: decoded_claims })
}
/// Decode a token and return the Header. This is not doing any kind of validation: it is meant to be
/// used when you don't know which `alg` the token is using and want to find out.
///
/// If the token has an invalid format, it will return an error.
///
/// ```rust,ignore
/// use jsonwebtoken::decode_header;
///
/// let token = "a.jwt.token".to_string();
/// let header = decode_header(&token);
/// ```
pub fn decode_header(token: &str) -> Result<Header> {
let (_, signing_input) = expect_two!(token.rsplitn(2, '.'));
let (_, header) = expect_two!(signing_input.rsplitn(2, '.'));
from_jwt_part(header)
}

View File

@@ -1,42 +0,0 @@
use base64;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use serde_json::{from_str, to_string, Value};
use serde_json::map::Map;
use errors::{Result};
use header::Header;
/// The return type of a successful call to decode
#[derive(Debug)]
pub struct TokenData<T> {
/// The decoded JWT header
pub header: Header,
/// The decoded JWT claims
pub claims: T
}
/// Serializes to JSON and encodes to base64
pub fn to_jwt_part<T: Serialize>(input: &T) -> Result<String> {
let encoded = to_string(input)?;
Ok(base64::encode_config(encoded.as_bytes(), base64::URL_SAFE_NO_PAD))
}
/// Decodes from base64 and deserializes from JSON to a struct
pub fn from_jwt_part<B: AsRef<str>, T: DeserializeOwned>(encoded: B) -> Result<T> {
let decoded = base64::decode_config(encoded.as_ref(), base64::URL_SAFE_NO_PAD)?;
let s = String::from_utf8(decoded)?;
Ok(from_str(&s)?)
}
/// Decodes from base64 and deserializes from JSON to a struct AND a hashmap
pub fn from_jwt_part_claims<B: AsRef<str>, T: DeserializeOwned>(encoded: B) -> Result<(T, Map<String, Value>)> {
let decoded = base64::decode_config(encoded.as_ref(), base64::URL_SAFE_NO_PAD)?;
let s = String::from_utf8(decoded)?;
let claims: T = from_str(&s)?;
let map: Map<_,_> = from_str(&s)?;
Ok((claims, map))
}

View File

@@ -1,377 +0,0 @@
use chrono::Utc;
use serde::ser::Serialize;
use serde_json::{Value, from_value, to_value};
use serde_json::map::Map;
use errors::{Result, ErrorKind};
use crypto::Algorithm;
/// Contains the various validations that are applied after decoding a token.
///
/// All time validation happen on UTC timestamps.
///
/// ```rust
/// use jsonwebtoken::Validation;
///
/// // Default value
/// let validation = Validation::default();
///
/// // Changing one parameter
/// let mut validation = Validation {leeway: 60, ..Default::default()};
///
/// // Setting audience
/// let mut validation = Validation::default();
/// validation.set_audience(&"Me"); // string
/// validation.set_audience(&["Me", "You"]); // array of strings
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Validation {
/// Add some leeway (in seconds) to the `exp`, `iat` and `nbf` validation to
/// account for clock skew.
///
/// Defaults to `0`.
pub leeway: i64,
/// Whether to validate the `exp` field.
///
/// It will return an error if the time in the `exp` field is past.
///
/// Defaults to `true`.
pub validate_exp: bool,
/// Whether to validate the `iat` field.
///
/// It will return an error if the time in the `iat` field is in the future.
///
/// Defaults to `true`.
pub validate_iat: bool,
/// Whether to validate the `nbf` field.
///
/// It will return an error if the current timestamp is before the time in the `nbf` field.
///
/// Defaults to `true`.
pub validate_nbf: bool,
/// If it contains a value, the validation will check that the `aud` field is the same as the
/// one provided and will error otherwise.
/// Since `aud` can be either a String or a Vec<String> in the JWT spec, you will need to use
/// the [set_audience](struct.Validation.html#method.set_audience) method to set it.
///
/// Defaults to `None`.
pub aud: Option<Value>,
/// If it contains a value, the validation will check that the `iss` field is the same as the
/// one provided and will error otherwise.
///
/// Defaults to `None`.
pub iss: Option<String>,
/// If it contains a value, the validation will check that the `sub` field is the same as the
/// one provided and will error otherwise.
///
/// Defaults to `None`.
pub sub: Option<String>,
/// If it contains a value, the validation will check that the `alg` of the header is contained
/// in the ones provided and will error otherwise.
///
/// Defaults to `vec![Algorithm::HS256]`.
pub algorithms: Vec<Algorithm>,
}
impl Validation {
/// Create a default validation setup allowing the given alg
pub fn new(alg: Algorithm) -> Validation {
let mut validation = Validation::default();
validation.algorithms = vec![alg];
validation
}
/// Since `aud` can be either a String or an array of String in the JWT spec, this method will take
/// care of serializing the value.
pub fn set_audience<T: Serialize>(&mut self, audience: &T) {
self.aud = Some(to_value(audience).unwrap());
}
}
impl Default for Validation {
fn default() -> Validation {
Validation {
leeway: 0,
validate_exp: true,
validate_iat: true,
validate_nbf: true,
iss: None,
sub: None,
aud: None,
algorithms: vec![Algorithm::HS256],
}
}
}
pub fn validate(claims: &Map<String, Value>, options: &Validation) -> Result<()> {
let now = Utc::now().timestamp();
if let Some(iat) = claims.get("iat") {
if options.validate_iat && from_value::<i64>(iat.clone())? > now + options.leeway {
return Err(ErrorKind::InvalidIssuedAt.into());
}
}
if let Some(exp) = claims.get("exp") {
if options.validate_exp && from_value::<i64>(exp.clone())? < now - options.leeway {
return Err(ErrorKind::ExpiredSignature.into());
}
}
if let Some(nbf) = claims.get("nbf") {
if options.validate_nbf && from_value::<i64>(nbf.clone())? > now + options.leeway {
return Err(ErrorKind::ImmatureSignature.into());
}
}
if let Some(iss) = claims.get("iss") {
if let Some(ref correct_iss) = options.iss {
if from_value::<String>(iss.clone())? != *correct_iss {
return Err(ErrorKind::InvalidIssuer.into());
}
}
}
if let Some(sub) = claims.get("sub") {
if let Some(ref correct_sub) = options.sub {
if from_value::<String>(sub.clone())? != *correct_sub {
return Err(ErrorKind::InvalidSubject.into());
}
}
}
if let Some(aud) = claims.get("aud") {
if let Some(ref correct_aud) = options.aud {
if aud != correct_aud {
return Err(ErrorKind::InvalidAudience.into());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::{to_value};
use serde_json::map::Map;
use chrono::Utc;
use super::{validate, Validation};
use errors::ErrorKind;
#[test]
fn iat_in_past_ok() {
let mut claims = Map::new();
claims.insert("iat".to_string(), to_value(Utc::now().timestamp() - 10000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_ok());
}
#[test]
fn iat_in_future_fails() {
let mut claims = Map::new();
claims.insert("iat".to_string(), to_value(Utc::now().timestamp() + 100000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::InvalidIssuedAt => (),
_ => assert!(false),
};
}
#[test]
fn iat_in_future_but_in_leeway_ok() {
let mut claims = Map::new();
claims.insert("iat".to_string(), to_value(Utc::now().timestamp() + 50).unwrap());
let validation = Validation {
leeway: 1000 * 60,
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn exp_in_future_ok() {
let mut claims = Map::new();
claims.insert("exp".to_string(), to_value(Utc::now().timestamp() + 10000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_ok());
}
#[test]
fn exp_in_past_fails() {
let mut claims = Map::new();
claims.insert("exp".to_string(), to_value(Utc::now().timestamp() - 100000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::ExpiredSignature => (),
_ => assert!(false),
};
}
#[test]
fn exp_in_past_but_in_leeway_ok() {
let mut claims = Map::new();
claims.insert("exp".to_string(), to_value(Utc::now().timestamp() - 500).unwrap());
let validation = Validation {
leeway: 1000 * 60,
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn nbf_in_past_ok() {
let mut claims = Map::new();
claims.insert("nbf".to_string(), to_value(Utc::now().timestamp() - 10000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_ok());
}
#[test]
fn nbf_in_future_fails() {
let mut claims = Map::new();
claims.insert("nbf".to_string(), to_value(Utc::now().timestamp() + 100000).unwrap());
let res = validate(&claims, &Validation::default());
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::ImmatureSignature => (),
_ => assert!(false),
};
}
#[test]
fn nbf_in_future_but_in_leeway_ok() {
let mut claims = Map::new();
claims.insert("nbf".to_string(), to_value(Utc::now().timestamp() + 500).unwrap());
let validation = Validation {
leeway: 1000 * 60,
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn iss_ok() {
let mut claims = Map::new();
claims.insert("iss".to_string(), to_value("Keats").unwrap());
let validation = Validation {
iss: Some("Keats".to_string()),
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn iss_not_matching_fails() {
let mut claims = Map::new();
claims.insert("iss".to_string(), to_value("Hacked").unwrap());
let validation = Validation {
iss: Some("Keats".to_string()),
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::InvalidIssuer => (),
_ => assert!(false),
};
}
#[test]
fn sub_ok() {
let mut claims = Map::new();
claims.insert("sub".to_string(), to_value("Keats").unwrap());
let validation = Validation {
sub: Some("Keats".to_string()),
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn sub_not_matching_fails() {
let mut claims = Map::new();
claims.insert("sub".to_string(), to_value("Hacked").unwrap());
let validation = Validation {
sub: Some("Keats".to_string()),
..Default::default()
};
let res = validate(&claims, &validation);
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::InvalidSubject => (),
_ => assert!(false),
};
}
#[test]
fn aud_string_ok() {
let mut claims = Map::new();
claims.insert("aud".to_string(), to_value("Everyone").unwrap());
let mut validation = Validation::default();
validation.set_audience(&"Everyone");
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn aud_array_of_string_ok() {
let mut claims = Map::new();
claims.insert("aud".to_string(), to_value(["UserA", "UserB"]).unwrap());
let mut validation = Validation::default();
validation.set_audience(&["UserA", "UserB"]);
let res = validate(&claims, &validation);
assert!(res.is_ok());
}
#[test]
fn aud_type_mismatch_fails() {
let mut claims = Map::new();
claims.insert("aud".to_string(), to_value("Everyone").unwrap());
let mut validation = Validation::default();
validation.set_audience(&["UserA", "UserB"]);
let res = validate(&claims, &validation);
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::InvalidAudience => (),
_ => assert!(false),
};
}
#[test]
fn aud_correct_type_not_matching_fails() {
let mut claims = Map::new();
claims.insert("aud".to_string(), to_value("Everyone").unwrap());
let mut validation = Validation::default();
validation.set_audience(&"None");
let res = validate(&claims, &validation);
assert!(res.is_err());
match res.unwrap_err().kind() {
&ErrorKind::InvalidAudience => (),
_ => assert!(false),
};
}
}

View File

@@ -0,0 +1,7 @@
ALTER TABLE users
ADD COLUMN
client_kdf_type INTEGER NOT NULL DEFAULT 0; -- PBKDF2
ALTER TABLE users
ADD COLUMN
client_kdf_iter INTEGER NOT NULL DEFAULT 5000;

View File

@@ -0,0 +1,3 @@
ALTER TABLE attachments
ADD COLUMN
key TEXT;

View File

@@ -1 +1 @@
nightly-2018-08-24
nightly-2019-04-26

1
rustfmt.toml Normal file
View File

@@ -0,0 +1 @@
max_width = 120

238
src/api/admin.rs Normal file
View File

@@ -0,0 +1,238 @@
use serde_json::Value;
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, JsonResult};
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;
pub fn routes() -> Vec<Route> {
if CONFIG.admin_token().is_none() && !CONFIG.disable_admin_token() {
return routes![admin_disabled];
}
routes![
admin_login,
get_users,
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,
}
#[post("/invite", data = "<data>")]
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() {
err!("Invitations are not allowed")
}
let mut user = User::new(email);
user.save(&conn)?;
if CONFIG.mail_enabled() {
let org_name = "bitwarden_rs";
mail::send_invite(&user.email, &user.uuid, None, None, &org_name, None)
} else {
let invitation = Invitation::new(data.email);
invitation.save(&conn)
}
}
#[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)))
}
#[post("/users/<uuid>/delete")]
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)
}
#[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 {}
impl<'a, 'r> FromRequest<'a, 'r> for AdminToken {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
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
};
let ip = match request.guard::<ClientIp>() {
Outcome::Success(ip) => ip.ip,
_ => err_handler!("Error getting Client 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

@@ -1,24 +1,51 @@
use rocket_contrib::Json;
use rocket_contrib::json::Json;
use db::DbConn;
use db::models::*;
use crate::db::models::*;
use crate::db::DbConn;
use api::{PasswordData, JsonResult, EmptyResult, JsonUpcase, NumberOrString};
use auth::Headers;
use fast_chemail::is_valid_email;
use mail;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType};
use crate::auth::{decode_invite, Headers};
use crate::mail;
use CONFIG;
use crate::CONFIG;
use rocket::Route;
pub fn routes() -> Vec<Route> {
routes![
register,
profile,
put_profile,
post_profile,
get_public_keys,
post_keys,
post_password,
post_kdf,
post_rotatekey,
post_sstamp,
post_email_token,
post_email,
delete_account,
post_delete_account,
revision_date,
password_hint,
prelogin,
]
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct RegisterData {
Email: String,
Kdf: Option<i32>,
KdfIterations: Option<i32>,
Key: String,
Keys: Option<KeysData>,
MasterPasswordHash: String,
MasterPasswordHint: Option<String>,
Name: Option<String>,
Token: Option<String>,
OrganizationUserId: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -32,32 +59,52 @@ struct KeysData {
fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
let data: RegisterData = data.into_inner().data;
let mut user = match User::find_by_mail(&data.Email, &conn) {
Some(mut user) => {
if Invitation::take(&data.Email, &conn) {
Some(user) => {
if !user.password_hash.is_empty() {
err!("User already exists")
}
if let Some(token) = data.Token {
let claims = decode_invite(&token)?;
if claims.email == data.Email {
user
} else {
err!("Registration email does not match invite email")
}
} else if Invitation::take(&data.Email, &conn) {
for mut user_org in UserOrganization::find_invited_by_user(&user.uuid, &conn).iter_mut() {
user_org.status = UserOrgStatus::Accepted as i32;
user_org.save(&conn);
};
user
} else {
if CONFIG.signups_allowed {
err!("Account with this email already exists")
} else {
err!("Registration not allowed")
user_org.save(&conn)?;
}
user
} 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) {
User::new(data.Email)
if CONFIG.signups_allowed() || Invitation::take(&data.Email, &conn) {
User::new(data.Email.clone())
} else {
err!("Registration not allowed")
}
}
};
// Make sure we don't leave a lingering invitation.
Invitation::take(&data.Email, &conn);
if let Some(client_kdf_iter) = data.KdfIterations {
user.client_kdf_iter = client_kdf_iter;
}
if let Some(client_kdf_type) = data.Kdf {
user.client_kdf_type = client_kdf_type;
}
user.set_password(&data.MasterPasswordHash);
user.key = data.Key;
@@ -75,9 +122,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
user.public_key = Some(keys.PublicKey);
}
user.save(&conn);
Ok(())
user.save(&conn)
}
#[get("/accounts/profile")]
@@ -89,7 +134,7 @@ fn profile(headers: Headers, conn: DbConn) -> JsonResult {
#[allow(non_snake_case)]
struct ProfileData {
#[serde(rename = "Culture")]
_Culture: String, // Ignored, always use en-US
_Culture: String, // Ignored, always use en-US
MasterPasswordHint: Option<String>,
Name: String,
}
@@ -110,8 +155,7 @@ fn post_profile(data: JsonUpcase<ProfileData>, headers: Headers, conn: DbConn) -
Some(ref h) if h.is_empty() => None,
_ => data.MasterPasswordHint,
};
user.save(&conn);
user.save(&conn)?;
Ok(Json(user.to_json(&conn)))
}
@@ -119,7 +163,7 @@ fn post_profile(data: JsonUpcase<ProfileData>, headers: Headers, conn: DbConn) -
fn get_public_keys(uuid: String, _headers: Headers, conn: DbConn) -> JsonResult {
let user = match User::find_by_uuid(&uuid, &conn) {
Some(user) => user,
None => err!("User doesn't exist")
None => err!("User doesn't exist"),
};
Ok(Json(json!({
@@ -138,8 +182,7 @@ fn post_keys(data: JsonUpcase<KeysData>, headers: Headers, conn: DbConn) -> Json
user.private_key = Some(data.EncryptedPrivateKey);
user.public_key = Some(data.PublicKey);
user.save(&conn);
user.save(&conn)?;
Ok(Json(user.to_json(&conn)))
}
@@ -162,9 +205,112 @@ fn post_password(data: JsonUpcase<ChangePassData>, headers: Headers, conn: DbCon
user.set_password(&data.NewMasterPasswordHash);
user.key = data.Key;
user.save(&conn);
user.save(&conn)
}
Ok(())
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct ChangeKdfData {
Kdf: i32,
KdfIterations: i32,
MasterPasswordHash: String,
NewMasterPasswordHash: String,
Key: String,
}
#[post("/accounts/kdf", data = "<data>")]
fn post_kdf(data: JsonUpcase<ChangeKdfData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data: ChangeKdfData = data.into_inner().data;
let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password")
}
user.client_kdf_iter = data.KdfIterations;
user.client_kdf_type = data.Kdf;
user.set_password(&data.NewMasterPasswordHash);
user.key = data.Key;
user.save(&conn)
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct UpdateFolderData {
Id: String,
Name: String,
}
use super::ciphers::CipherData;
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct KeyData {
Ciphers: Vec<CipherData>,
Folders: Vec<UpdateFolderData>,
Key: String,
PrivateKey: String,
MasterPasswordHash: String,
}
#[post("/accounts/key", data = "<data>")]
fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let data: KeyData = data.into_inner().data;
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password")
}
let user_uuid = &headers.user.uuid;
// Update folder data
for folder_data in data.Folders {
let mut saved_folder = match Folder::find_by_uuid(&folder_data.Id, &conn) {
Some(folder) => folder,
None => err!("Folder doesn't exist"),
};
if &saved_folder.user_uuid != user_uuid {
err!("The folder is not owned by the user")
}
saved_folder.name = folder_data.Name;
saved_folder.save(&conn)?
}
// Update cipher data
use super::ciphers::update_cipher_from_data;
for cipher_data in data.Ciphers {
let mut saved_cipher = match Cipher::find_by_uuid(cipher_data.Id.as_ref().unwrap(), &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
};
if saved_cipher.user_uuid.as_ref().unwrap() != user_uuid {
err!("The cipher is not owned by the user")
}
update_cipher_from_data(
&mut saved_cipher,
cipher_data,
&headers,
false,
&conn,
&nt,
UpdateType::CipherUpdate,
)?
}
// Update user data
let mut user = headers.user;
user.key = data.Key;
user.private_key = Some(data.PrivateKey);
user.reset_security_stamp();
user.save(&conn)
}
#[post("/accounts/security-stamp", data = "<data>")]
@@ -176,10 +322,9 @@ 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);
Ok(())
user.save(&conn)
}
#[derive(Deserialize)]
@@ -234,12 +379,15 @@ fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn)
user.set_password(&data.NewMasterPasswordHash);
user.key = data.Key;
user.save(&conn);
Ok(())
user.save(&conn)
}
#[post("/accounts/delete", data = "<data>")]
fn post_delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> EmptyResult {
delete_account(data, headers, conn)
}
#[delete("/accounts", data = "<data>")]
fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data: PasswordData = data.into_inner().data;
let user = headers.user;
@@ -248,27 +396,7 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
err!("Invalid password")
}
// Delete ciphers and their attachments
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
if cipher.delete(&conn).is_err() {
err!("Failed deleting cipher")
}
}
// Delete folders
for f in Folder::find_by_user(&user.uuid, &conn) {
if f.delete(&conn).is_err() {
err!("Failed deleting folder")
}
}
// Delete devices
for d in Device::find_by_user(&user.uuid, &conn) { d.delete(&conn); }
// Delete user
user.delete(&conn);
Ok(())
user.delete(&conn)
}
#[get("/accounts/revision-date")]
@@ -287,25 +415,18 @@ struct PasswordHintData {
fn password_hint(data: JsonUpcase<PasswordHintData>, conn: DbConn) -> EmptyResult {
let data: PasswordHintData = data.into_inner().data;
if !is_valid_email(&data.Email) {
err!("This email address is not valid...");
}
let hint = match User::find_by_mail(&data.Email, &conn) {
Some(user) => user.password_hint,
None => return Ok(()),
};
let user = User::find_by_mail(&data.Email, &conn);
if user.is_none() {
return Ok(());
}
let user = user.unwrap();
if let Some(ref mail_config) = CONFIG.mail {
if let Err(e) = mail::send_password_hint(&user.email, user.password_hint, mail_config) {
err!(format!("There have been a problem sending the email: {}", e));
}
} else if CONFIG.show_password_hint {
if let Some(hint) = user.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 {
err!(format!("Sorry, you have no password hint..."));
err!("Sorry, you have no password hint...");
}
}
@@ -322,20 +443,13 @@ struct PreloginData {
fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> JsonResult {
let data: PreloginData = data.into_inner().data;
match User::find_by_mail(&data.Email, &conn) {
Some(user) => {
let kdf_type = 0; // PBKDF2: 0
let (kdf_type, kdf_iter) = match User::find_by_mail(&data.Email, &conn) {
Some(user) => (user.client_kdf_type, user.client_kdf_iter),
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT),
};
let _server_iter = user.password_iterations;
let client_iter = 5000; // TODO: Make iterations user configurable
Ok(Json(json!({
"Kdf": kdf_type,
"KdfIterations": client_iter
})))
},
None => err!("Invalid user"),
}
Ok(Json(json!({
"Kdf": kdf_type,
"KdfIterations": kdf_iter
})))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,36 @@
use rocket_contrib::{Json, Value};
use rocket_contrib::json::Json;
use serde_json::Value;
use db::DbConn;
use db::models::*;
use crate::db::models::*;
use crate::db::DbConn;
use api::{JsonResult, EmptyResult, JsonUpcase};
use auth::Headers;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType};
use crate::auth::Headers;
use rocket::Route;
pub fn routes() -> Vec<Route> {
routes![
get_folders,
get_folder,
post_folders,
post_folder,
put_folder,
delete_folder_post,
delete_folder,
]
}
#[get("/folders")]
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,
"Object": "list",
"ContinuationToken": null,
})))
}
@@ -22,7 +38,7 @@ fn get_folders(headers: Headers, conn: DbConn) -> JsonResult {
fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> JsonResult {
let folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
_ => err!("Invalid folder"),
};
if folder.user_uuid != headers.user.uuid {
@@ -36,32 +52,33 @@ fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> JsonResult {
#[allow(non_snake_case)]
pub struct FolderData {
pub Name: String
pub Name: String,
}
#[post("/folders", data = "<data>")]
fn post_folders(data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn) -> JsonResult {
fn post_folders(data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
let data: FolderData = data.into_inner().data;
let mut folder = Folder::new(headers.user.uuid.clone(), data.Name);
folder.save(&conn);
folder.save(&conn)?;
nt.send_folder_update(UpdateType::FolderCreate, &folder);
Ok(Json(folder.to_json()))
}
#[post("/folders/<uuid>", data = "<data>")]
fn post_folder(uuid: String, data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn) -> JsonResult {
put_folder(uuid, data, headers, conn)
fn post_folder(uuid: String, data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
put_folder(uuid, data, headers, conn, nt)
}
#[put("/folders/<uuid>", data = "<data>")]
fn put_folder(uuid: String, data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn) -> JsonResult {
fn put_folder(uuid: String, data: JsonUpcase<FolderData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
let data: FolderData = data.into_inner().data;
let mut folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
_ => err!("Invalid folder"),
};
if folder.user_uuid != headers.user.uuid {
@@ -70,21 +87,22 @@ fn put_folder(uuid: String, data: JsonUpcase<FolderData>, headers: Headers, conn
folder.name = data.Name;
folder.save(&conn);
folder.save(&conn)?;
nt.send_folder_update(UpdateType::FolderUpdate, &folder);
Ok(Json(folder.to_json()))
}
#[post("/folders/<uuid>/delete")]
fn delete_folder_post(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
delete_folder(uuid, headers, conn)
fn delete_folder_post(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
delete_folder(uuid, headers, conn, nt)
}
#[delete("/folders/<uuid>")]
fn delete_folder(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
fn delete_folder(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let folder = match Folder::find_by_uuid(&uuid, &conn) {
Some(folder) => folder,
_ => err!("Invalid folder")
_ => err!("Invalid folder"),
};
if folder.user_uuid != headers.user.uuid {
@@ -92,8 +110,8 @@ fn delete_folder(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
}
// Delete the actual folder entry
match folder.delete(&conn) {
Ok(()) => Ok(()),
Err(_) => err!("Failed deleting folder")
}
folder.delete(&conn)?;
nt.send_folder_update(UpdateType::FolderDelete, &folder);
Ok(())
}

View File

@@ -4,171 +4,69 @@ mod folders;
mod organizations;
pub(crate) mod two_factor;
use self::accounts::*;
use self::ciphers::*;
use self::folders::*;
use self::organizations::*;
use self::two_factor::*;
pub fn routes() -> Vec<Route> {
routes![
register,
profile,
put_profile,
post_profile,
get_public_keys,
post_keys,
post_password,
post_sstamp,
post_email_token,
post_email,
delete_account,
revision_date,
password_hint,
prelogin,
sync,
get_ciphers,
get_cipher,
get_cipher_admin,
get_cipher_details,
post_ciphers,
put_cipher_admin,
post_ciphers_admin,
post_ciphers_import,
post_attachment,
post_attachment_admin,
post_attachment_share,
delete_attachment_post,
delete_attachment_post_admin,
delete_attachment,
delete_attachment_admin,
post_cipher_admin,
post_cipher_share,
put_cipher_share,
put_cipher_share_seleted,
post_cipher,
put_cipher,
delete_cipher_post,
delete_cipher_post_admin,
delete_cipher,
delete_cipher_admin,
delete_cipher_selected,
delete_cipher_selected_post,
delete_all,
move_cipher_selected,
move_cipher_selected_put,
get_folders,
get_folder,
post_folders,
post_folder,
put_folder,
delete_folder_post,
delete_folder,
get_twofactor,
get_recover,
recover,
disable_twofactor,
disable_twofactor_put,
generate_authenticator,
activate_authenticator,
activate_authenticator_put,
generate_u2f,
activate_u2f,
activate_u2f_put,
get_organization,
create_organization,
delete_organization,
post_delete_organization,
leave_organization,
get_user_collections,
get_org_collections,
get_org_collection_detail,
get_collection_users,
put_organization,
post_organization,
post_organization_collections,
delete_organization_collection_user,
post_organization_collection_delete_user,
post_organization_collection_update,
put_organization_collection_update,
delete_organization_collection,
post_organization_collection_delete,
post_collections_update,
post_collections_admin,
put_collections_admin,
get_org_details,
get_org_users,
send_invite,
confirm_invite,
get_user,
edit_user,
put_organization_user,
delete_user,
post_delete_user,
post_org_import,
let mut mod_routes = routes![
clear_device_token,
put_device_token,
get_eq_domains,
post_eq_domains,
put_eq_domains,
hibp_breach,
];
]
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
routes.append(&mut ciphers::routes());
routes.append(&mut folders::routes());
routes.append(&mut organizations::routes());
routes.append(&mut two_factor::routes());
routes.append(&mut mod_routes);
routes
}
///
/// Move this somewhere else
///
//
// Move this somewhere else
//
use rocket::Route;
use rocket_contrib::{Json, Value};
use rocket_contrib::json::Json;
use serde_json::Value;
use db::DbConn;
use db::models::*;
use crate::api::{EmptyResult, JsonResult, JsonUpcase};
use crate::auth::Headers;
use crate::db::DbConn;
use crate::error::Error;
use api::{JsonResult, EmptyResult, JsonUpcase};
use auth::Headers;
#[put("/devices/identifier/<uuid>/clear-token")]
fn clear_device_token(uuid: String) -> EmptyResult {
// This endpoint doesn't have auth header
#[put("/devices/identifier/<uuid>/clear-token", data = "<data>")]
fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> EmptyResult {
let _data: Value = data.into_inner();
let device = match Device::find_by_uuid(&uuid, &conn) {
Some(device) => device,
None => err!("Device not found")
};
if device.user_uuid != headers.user.uuid {
err!("Device not owned by user")
}
device.delete(&conn);
let _ = uuid;
// uuid is not related to deviceId
// This only clears push token
// https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109
// https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37
Ok(())
}
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
fn put_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
let _data: Value = data.into_inner();
fn put_device_token(uuid: String, data: JsonUpcase<Value>, headers: Headers) -> JsonResult {
let _data: Value = data.into_inner().data;
// Data has a single string value "PushToken"
let _ = uuid;
// uuid is not related to deviceId
let device = match Device::find_by_uuid(&uuid, &conn) {
Some(device) => device,
None => err!("Device not found")
};
// TODO: This should save the push token, but we don't have push functionality
if device.user_uuid != headers.user.uuid {
err!("Device not owned by user")
}
// TODO: What does this do?
err!("Not implemented")
Ok(Json(json!({
"Id": headers.device.uuid,
"Name": headers.device.name,
"Type": headers.device.type_,
"Identifier": headers.device.uuid,
"CreationDate": crate::util::format_date(&headers.device.created_at),
})))
}
#[derive(Serialize, Deserialize, Debug)]
@@ -179,7 +77,7 @@ struct GlobalDomain {
Excluded: bool,
}
const GLOBAL_DOMAINS: &str = include_str!("global_domains.json");
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
#[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> JsonResult {
@@ -202,7 +100,6 @@ fn get_eq_domains(headers: Headers) -> JsonResult {
})))
}
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct EquivDomainData {
@@ -211,7 +108,7 @@ struct EquivDomainData {
}
#[post("/settings/domains", data = "<data>")]
fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> EmptyResult {
fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EquivDomainData = data.into_inner().data;
let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or_default();
@@ -220,10 +117,33 @@ 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);
user.save(&conn)?;
Ok(())
Ok(Json(json!({})))
}
#[put("/settings/domains", data = "<data>")]
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))
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,73 @@
use std::fs::{create_dir_all, remove_file, symlink_metadata, File};
use std::io::prelude::*;
use std::fs::{create_dir_all, File};
use std::time::{Duration, SystemTime};
use rocket::Route;
use rocket::response::Content;
use rocket::http::ContentType;
use rocket::response::Content;
use rocket::Route;
use reqwest;
use reqwest::{header::HeaderMap, Client, Response};
use CONFIG;
use rocket::http::Cookie;
use regex::Regex;
use soup::prelude::*;
use crate::error::Error;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![icon]
}
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("..") {
return Content(icon_type, get_fallback_icon());
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);
@@ -27,29 +75,42 @@ fn icon(domain: String) -> Content<Vec<u8>> {
Content(icon_type, icon)
}
fn get_icon (domain: &str) -> Vec<u8> {
let path = format!("{}/{}.png", CONFIG.icon_cache_folder, domain);
fn get_icon(domain: &str) -> Vec<u8> {
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
},
}
Err(e) => {
println!("Error downloading icon: {:?}", e);
get_fallback_icon()
error!("Error downloading icon: {:?}", e);
mark_negcache(&path);
FALLBACK_ICON.to_vec()
}
}
}
fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
// Check for expiration of negatively cached copy
if icon_is_negcached(path) {
return Some(FALLBACK_ICON.to_vec());
}
// Check for expiration of successfully cached copy
if icon_is_expired(path) {
return None;
}
// Try to read the cached icon, and return it if it exists
if let Ok(mut f) = File::open(path) {
let mut buffer = Vec::new();
@@ -62,51 +123,267 @@ fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None
}
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)
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)?;
Ok(ttl > 0 && ttl <= age.as_secs())
}
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());
match expired {
// No longer negatively cached, drop the marker
Ok(true) => {
if let Err(e) = remove_file(&miss_indicator) {
error!("Could not remove negative cache indicator for icon {:?}: {:?}", path, e);
}
false
}
// The marker hasn't expired yet.
Ok(false) => true,
// The marker is missing or inaccessible in some way.
Err(_) => false,
}
}
fn download_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
println!("Downloading icon for {}...", url);
let mut res = reqwest::get(url)?;
fn mark_negcache(path: &str) {
let miss_indicator = path.to_owned() + ".miss";
File::create(&miss_indicator).expect("Error creating negative cache marker");
}
res = res.error_for_status()?;
fn icon_is_expired(path: &str) -> bool {
let expired = file_is_expired(path, CONFIG.icon_cache_ttl());
expired.unwrap_or(true)
}
let mut buffer: Vec<u8> = vec![];
res.copy_to(&mut buffer)?;
#[derive(Debug)]
struct Icon {
priority: u8,
href: String,
}
impl Icon {
fn new(priority: u8, href: String) -> Self {
Self { href, priority }
}
}
/// 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);
// Create the iconlist
let mut iconlist: Vec<Icon> = Vec::new();
// 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");
};
}
const FALLBACK_ICON_URL: &str = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
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::*;
fn get_fallback_icon() -> Vec<u8> {
let path = format!("{}/default.png", CONFIG.icon_cache_folder);
if let Some(icon) = get_cached_icon(&path) {
return icon;
macro_rules! headers {
($( $name:ident : $value:literal),+ $(,)? ) => {
let mut headers = HeaderMap::new();
$( headers.insert($name, HeaderValue::from_static($value)); )+
headers
};
}
match download_icon(FALLBACK_ICON_URL) {
Ok(icon) => {
save_icon(&path, &icon);
icon
},
Err(e) => {
println!("Error downloading fallback icon: {:?}", e);
vec![]
}
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

@@ -1,42 +1,57 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use rocket::request::{Form, FormItems, FromForm};
use rocket::Route;
use rocket::request::{self, Form, FormItems, FromForm, FromRequest, Request};
use rocket::{Outcome, Route};
use rocket_contrib::{Json, Value};
use rocket_contrib::json::Json;
use serde_json::Value;
use num_traits::FromPrimitive;
use db::models::*;
use db::DbConn;
use crate::db::models::*;
use crate::db::DbConn;
use util::{self, JsonMap};
use crate::util;
use api::{ApiResult, JsonResult};
use crate::api::{ApiResult, EmptyResult, JsonResult};
use CONFIG;
use crate::auth::ClientIp;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![login]
}
#[post("/connect/token", data = "<connect_data>")]
fn login(connect_data: Form<ConnectData>, device_type: DeviceType, conn: DbConn, socket: Option<SocketAddr>) -> JsonResult {
let data = connect_data.get();
#[post("/connect/token", data = "<data>")]
fn login(data: Form<ConnectData>, conn: DbConn, ip: ClientIp) -> JsonResult {
let data: ConnectData = data.into_inner();
match data.grant_type {
GrantType::RefreshToken => _refresh_login(data, device_type, conn),
GrantType::Password => _password_login(data, device_type, conn, socket),
match data.grant_type.as_ref() {
"refresh_token" => {
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
_refresh_login(data, conn)
}
"password" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?;
_check_is_some(&data.password, "password cannot be blank")?;
_check_is_some(&data.scope, "scope cannot be blank")?;
_check_is_some(&data.username, "username cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?;
_password_login(data, conn, ip)
}
t => err!("Invalid type", t),
}
}
fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) -> JsonResult {
fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
// Extract token
let token = data.get("refresh_token");
let token = data.refresh_token.unwrap();
// Get device by refresh token
let mut device = match Device::find_by_refresh_token(token, &conn) {
let mut device = match Device::find_by_refresh_token(&token, &conn) {
Some(device) => device,
None => err!("Invalid refresh token"),
};
@@ -46,8 +61,8 @@ fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) ->
let orgs = UserOrganization::find_by_user(&user.uuid, &conn);
let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
device.save(&conn);
device.save(&conn)?;
Ok(Json(json!({
"access_token": access_token,
"expires_in": expires_in,
@@ -58,65 +73,49 @@ fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) ->
})))
}
fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, remote: Option<SocketAddr>) -> JsonResult {
// Get the ip for error reporting
let ip = match remote {
Some(ip) => ip.ip(),
None => IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
};
fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult {
// Validate scope
let scope = data.get("scope");
let scope = data.scope.as_ref().unwrap();
if scope != "api offline_access" {
err!("Scope not supported")
}
// Get the user
let username = data.get("username");
let username = data.username.as_ref().unwrap();
let user = match User::find_by_mail(username, &conn) {
Some(user) => user,
None => err!(format!(
"Username or password is incorrect. Try again. IP: {}. Username: {}.",
ip, username
)),
None => err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", ip.ip, username)
),
};
// Check password
let password = data.get("password");
let password = data.password.as_ref().unwrap();
if !user.check_valid_password(password) {
err!(format!(
"Username or password is incorrect. Try again. IP: {}. Username: {}.",
ip, username
))
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", ip.ip, username)
)
}
// Let's only use the header and ignore the 'devicetype' parameter
let device_type_num = device_type.0;
let (device_id, device_name) = if data.is_device {
(
data.get("deviceidentifier").clone(),
data.get("devicename").clone(),
)
} else {
(format!("web-{}", user.uuid), String::from("web"))
};
// On iOS, device_type sends "iOS", on others it sends a number
let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(0);
let device_id = data.device_identifier.clone().expect("No device id provided");
let device_name = data.device_name.clone().expect("No device name provided");
// Find device or create new
let mut device = match Device::find_by_uuid(&device_id, &conn) {
Some(device) => {
// Check if valid device
// Check if owned device, and recreate if not
if device.user_uuid != user.uuid {
device.delete(&conn);
err!("Device is not owned by user")
info!("Device exists but is owned by another user. The old device will be discarded");
Device::new(device_id, user.uuid.clone(), device_name, device_type)
} else {
device
}
device
}
None => {
// Create new device
Device::new(device_id, user.uuid.clone(), device_name, device_type_num)
}
None => Device::new(device_id, user.uuid.clone(), device_name, device_type),
};
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
@@ -126,7 +125,7 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, re
let orgs = UserOrganization::find_by_user(&user.uuid, &conn);
let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
device.save(&conn);
device.save(&conn)?;
let mut result = json!({
"access_token": access_token,
@@ -142,6 +141,7 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, re
result["TwoFactorToken"] = Value::String(token);
}
info!("User {} logged in successfully. IP: {}", username, ip.ip);
Ok(Json(result))
}
@@ -151,63 +151,47 @@ 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.len() == 0 {
if twofactors.is_empty() {
return Ok(None);
}
let provider = match util::parse_option_string(data.get_opt("twoFactorProvider")) {
Some(provider) => provider,
None => 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(&twofactor_ids, user_uuid, conn)?),
};
let twofactor_code = match data.get_opt("twoFactorToken") {
Some(code) => code,
None => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
};
let selected_twofactor = twofactors.into_iter().filter(|tf| tf.type_ == selected_id).nth(0);
let twofactor = twofactors.iter().filter(|tf| tf.type_ == provider).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?)?,
Some(TwoFactorType::Duo) => _tf::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?,
match TwoFactorType::from_i32(provider) {
Some(TwoFactorType::Remember) => {
match &device.twofactor_remember {
Some(remember) if remember == twofactor_code => return Ok(None), // No twofactor token needed here
_ => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
match device.twofactor_remember {
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 api::core::two_factor;
two_factor::validate_u2f_login(user_uuid, twofactor_code, conn)?;
}
_ => err!("Invalid two factor provider"),
}
if util::parse_option_string(data.get_opt("twoFactorRemember")).unwrap_or(0) == 1 {
if !CONFIG.disable_2fa_remember() && remember == 1 {
Ok(Some(device.refresh_twofactor_remember()))
} else {
device.delete_twofactor_remember();
@@ -215,8 +199,15 @@ 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 api::core::two_factor;
use crate::api::core::two_factor;
let mut result = json!({
"error" : "invalid_grant",
@@ -231,31 +222,51 @@ 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();
for key in request.registered_keys {
let mut challenge_map = JsonMap::new();
challenge_map.insert("appId".into(), Value::String(request.app_id.clone()));
challenge_map
.insert("challenge".into(), Value::String(request.challenge.clone()));
challenge_map.insert("version".into(), Value::String(key.version));
challenge_map.insert(
"keyHandle".into(),
Value::String(key.key_handle.unwrap_or_default()),
);
challenge_list.push(Value::Object(challenge_map));
challenge_list.push(json!({
"appId": request.app_id,
"challenge": request.challenge,
"version": key.version,
"keyHandle": key.key_handle,
}));
}
let mut map = JsonMap::new();
use serde_json;
let challenge_list_str = serde_json::to_string(&challenge_list).unwrap();
map.insert("Challenges".into(), Value::String(challenge_list_str));
result["TwoFactorProviders2"][provider.to_string()] = Value::Object(map);
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Challenges": challenge_list_str,
});
}
Some(TwoFactorType::Duo) => {
let email = match User::find_by_uuid(user_uuid, &conn) {
Some(u) => u.email,
None => err!("User does not exist"),
};
let (signature, host) = two_factor::generate_duo_signature(&email, conn)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Host": host,
"Signature": signature,
});
}
Some(tf_type @ TwoFactorType::YubiKey) => {
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, tf_type as i32, &conn) {
Some(tf) => tf,
None => err!("No YubiKey devices registered"),
};
let yubikey_metadata: two_factor::YubikeyMetadata = serde_json::from_str(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Nfc": yubikey_metadata.Nfc,
})
}
_ => {}
@@ -265,93 +276,64 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
Ok(result)
}
#[derive(Clone, Copy)]
struct DeviceType(i32);
impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
let headers = request.headers();
let type_opt = headers.get_one("Device-Type");
let type_num = util::parse_option_string(type_opt).unwrap_or(0);
Outcome::Success(DeviceType(type_num))
}
}
#[derive(Debug)]
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
struct ConnectData {
grant_type: GrantType,
is_device: bool,
data: HashMap<String, String>,
grant_type: String, // refresh_token, password
// Needed for grant_type="refresh_token"
refresh_token: Option<String>,
// Needed for grant_type="password"
client_id: Option<String>, // web, cli, desktop, browser, mobile
password: Option<String>,
scope: Option<String>,
username: Option<String>,
device_identifier: Option<String>,
device_name: Option<String>,
device_type: Option<String>,
// Needed for two-factor auth
two_factor_provider: Option<i32>,
two_factor_token: Option<String>,
two_factor_remember: Option<i32>,
}
#[derive(Debug, Copy, Clone)]
enum GrantType {
RefreshToken,
Password,
}
impl ConnectData {
fn get(&self, key: &str) -> &String {
&self.data[&key.to_lowercase()]
}
fn get_opt(&self, key: &str) -> Option<&String> {
self.data.get(&key.to_lowercase())
}
}
const VALUES_REFRESH: [&str; 1] = ["refresh_token"];
const VALUES_PASSWORD: [&str; 5] = ["client_id", "grant_type", "password", "scope", "username"];
const VALUES_DEVICE: [&str; 3] = ["deviceidentifier", "devicename", "devicetype"];
impl<'f> FromForm<'f> for ConnectData {
type Error = String;
fn from_form(items: &mut FormItems<'f>, _strict: bool) -> Result<Self, Self::Error> {
let mut data = HashMap::new();
let mut form = Self::default();
for item in items {
let (key, value) = item.key_value_decoded();
let mut normalized_key = key.to_lowercase();
normalized_key.retain(|c| c != '_'); // Remove '_'
// Insert data into map
for (key, value) in items {
match (key.url_decode(), value.url_decode()) {
(Ok(key), Ok(value)) => data.insert(key.to_lowercase(), value),
_ => return Err("Error decoding key or value".to_string()),
};
match normalized_key.as_ref() {
"granttype" => form.grant_type = value,
"refreshtoken" => form.refresh_token = Some(value),
"clientid" => form.client_id = Some(value),
"password" => form.password = Some(value),
"scope" => form.scope = Some(value),
"username" => form.username = Some(value),
"deviceidentifier" => form.device_identifier = Some(value),
"devicename" => form.device_name = Some(value),
"devicetype" => form.device_type = Some(value),
"twofactorprovider" => form.two_factor_provider = value.parse().ok(),
"twofactortoken" => form.two_factor_token = Some(value),
"twofactorremember" => form.two_factor_remember = value.parse().ok(),
key => warn!("Detected unexpected parameter during login: {}", key),
}
}
// Validate needed values
let (grant_type, is_device) = match data.get("grant_type").map(String::as_ref) {
Some("refresh_token") => {
check_values(&data, &VALUES_REFRESH)?;
(GrantType::RefreshToken, false) // Device doesn't matter here
}
Some("password") => {
check_values(&data, &VALUES_PASSWORD)?;
let is_device = match data["client_id"].as_ref() {
"browser" | "mobile" => check_values(&data, &VALUES_DEVICE)?,
_ => false,
};
(GrantType::Password, is_device)
}
_ => return Err("Grant type not supported".to_string()),
};
Ok(ConnectData {
grant_type,
is_device,
data,
})
Ok(form)
}
}
fn check_values(map: &HashMap<String, String>, values: &[&str]) -> Result<bool, String> {
for value in values {
if !map.contains_key(*value) {
return Err(format!("{} cannot be blank", value));
}
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
if value.is_none() {
err!(msg)
}
Ok(true)
Ok(())
}

View File

@@ -1,31 +1,35 @@
mod admin;
pub(crate) mod core;
mod icons;
mod identity;
mod web;
mod notifications;
mod web;
pub use self::admin::routes as admin_routes;
pub use self::core::routes as core_routes;
pub use self::icons::routes as icons_routes;
pub use self::identity::routes as identity_routes;
pub use self::web::routes as web_routes;
pub use self::notifications::routes as notifications_routes;
pub use self::notifications::{start_notification_server, Notify, UpdateType};
pub use self::web::routes as web_routes;
use rocket::response::status::BadRequest;
use rocket_contrib::Json;
use rocket_contrib::json::Json;
use serde_json::Value;
// Type aliases for API methods results
type ApiResult<T> = Result<T, BadRequest<Json>>;
type JsonResult = ApiResult<Json>;
type EmptyResult = ApiResult<()>;
type ApiResult<T> = Result<T, crate::error::Error>;
pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
use util;
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)]
#[allow(non_snake_case)]
struct PasswordData {
MasterPasswordHash: String
MasterPasswordHash: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -39,14 +43,17 @@ impl NumberOrString {
fn into_string(self) -> String {
match self {
NumberOrString::Number(n) => n.to_string(),
NumberOrString::String(s) => s
NumberOrString::String(s) => s,
}
}
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

@@ -1,31 +1,364 @@
use rocket::Route;
use rocket_contrib::Json;
use rocket_contrib::json::Json;
use serde_json::Value as JsonValue;
use db::DbConn;
use api::JsonResult;
use auth::Headers;
use crate::api::JsonResult;
use crate::auth::Headers;
use crate::db::DbConn;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![negotiate]
routes![negotiate, websockets_err]
}
#[get("/hub")]
fn websockets_err() -> JsonResult {
err!("'/notifications/hub' should be proxied to the websocket server or notifications won't work. Go to the README for more info.")
}
#[post("/hub/negotiate")]
fn negotiate(_headers: Headers, _conn: DbConn) -> JsonResult {
use crate::crypto;
use data_encoding::BASE64URL;
use crypto;
// Store this in db?
let conn_id = BASE64URL.encode(&crypto::get_random(vec![0u8; 16]));
let mut available_transports: Vec<JsonValue> = Vec::new();
if CONFIG.websocket_enabled() {
available_transports.push(json!({"transport":"WebSockets", "transferFormats":["Text","Binary"]}));
}
// TODO: Implement transports
// Rocket WS support: https://github.com/SergioBenitez/Rocket/issues/90
// Rocket SSE support: https://github.com/SergioBenitez/Rocket/issues/33
// {"transport":"ServerSentEvents", "transferFormats":["Text"]},
// {"transport":"LongPolling", "transferFormats":["Text","Binary"]}
Ok(Json(json!({
"connectionId": conn_id,
"availableTransports":[
// {"transport":"WebSockets", "transferFormats":["Text","Binary"]},
// {"transport":"ServerSentEvents", "transferFormats":["Text"]},
// {"transport":"LongPolling", "transferFormats":["Text","Binary"]}
]
"availableTransports": available_transports
})))
}
//
// Websockets server
//
use std::sync::Arc;
use std::thread;
use ws::{self, util::Token, Factory, Handler, Handshake, Message, Sender, WebSocket};
use chashmap::CHashMap;
use chrono::NaiveDateTime;
use serde_json::from_str;
use crate::db::models::{Cipher, Folder, User};
use rmpv::Value;
fn serialize(val: Value) -> Vec<u8> {
use rmpv::encode::write_value;
let mut buf = Vec::new();
write_value(&mut buf, &val).expect("Error encoding MsgPack");
// Add size bytes at the start
// Extracted from BinaryMessageFormat.js
let mut size: usize = buf.len();
let mut len_buf: Vec<u8> = Vec::new();
loop {
let mut size_part = size & 0x7f;
size >>= 7;
if size > 0 {
size_part |= 0x80;
}
len_buf.push(size_part as u8);
if size == 0 {
break;
}
}
len_buf.append(&mut buf);
len_buf
}
fn serialize_date(date: NaiveDateTime) -> Value {
let seconds: i64 = date.timestamp();
let nanos: i64 = date.timestamp_subsec_nanos().into();
let timestamp = nanos << 34 | seconds;
let bs = timestamp.to_be_bytes();
// -1 is Timestamp
// https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
Value::Ext(-1, bs.to_vec())
}
fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
match option {
Some(a) => a.into(),
None => Value::Nil,
}
}
// Server WebSocket handler
pub struct WSHandler {
out: Sender,
user_uuid: Option<String>,
users: WebSocketUsers,
}
const RECORD_SEPARATOR: u8 = 0x1e;
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
#[derive(Deserialize)]
struct InitialMessage {
protocol: String,
version: i32,
}
const PING_MS: u64 = 15_000;
const PING: Token = Token(1);
impl Handler for WSHandler {
fn on_open(&mut self, hs: Handshake) -> ws::Result<()> {
// TODO: Improve this split
let path = hs.request.resource();
let mut query_split: Vec<_> = path.split('?').nth(1).unwrap().split('&').collect();
query_split.sort();
let access_token = &query_split[0][13..];
let _id = &query_split[1][3..];
// Validate the user
use crate::auth;
let claims = match auth::decode_login(access_token) {
Ok(claims) => claims,
Err(_) => return Err(ws::Error::new(ws::ErrorKind::Internal, "Invalid access token provided")),
};
// Assign the user to the handler
let user_uuid = claims.sub;
self.user_uuid = Some(user_uuid.clone());
// Add the current Sender to the user list
let handler_insert = self.out.clone();
let handler_update = self.out.clone();
self.users
.map
.upsert(user_uuid, || vec![handler_insert], |ref mut v| v.push(handler_update));
// Schedule a ping to keep the connection alive
self.out.timeout(PING_MS, PING)
}
fn on_message(&mut self, msg: Message) -> ws::Result<()> {
info!("Server got message '{}'. ", msg);
if let Message::Text(text) = msg.clone() {
let json = &text[..text.len() - 1]; // Remove last char
if let Ok(InitialMessage { protocol, version }) = from_str::<InitialMessage>(json) {
if &protocol == "messagepack" && version == 1 {
return self.out.send(&INITIAL_RESPONSE[..]); // Respond to initial message
}
}
}
// If it's not the initial message, just echo the message
self.out.send(msg)
}
fn on_timeout(&mut self, event: Token) -> ws::Result<()> {
if event == PING {
// send ping
self.out.send(create_ping())?;
// reschedule the timeout
self.out.timeout(PING_MS, PING)
} else {
Err(ws::Error::new(
ws::ErrorKind::Internal,
"Invalid timeout token provided",
))
}
}
}
struct WSFactory {
pub users: WebSocketUsers,
}
impl WSFactory {
pub fn init() -> Self {
WSFactory {
users: WebSocketUsers {
map: Arc::new(CHashMap::new()),
},
}
}
}
impl Factory for WSFactory {
type Handler = WSHandler;
fn connection_made(&mut self, out: Sender) -> Self::Handler {
WSHandler {
out,
user_uuid: None,
users: self.users.clone(),
}
}
fn connection_lost(&mut self, handler: Self::Handler) {
// Remove handler
if let Some(user_uuid) = &handler.user_uuid {
if let Some(mut user_conn) = self.users.map.get_mut(user_uuid) {
user_conn.remove_item(&handler.out);
}
}
}
}
#[derive(Clone)]
pub struct WebSocketUsers {
map: Arc<CHashMap<String, Vec<Sender>>>,
}
impl WebSocketUsers {
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)?;
}
}
Ok(())
}
// NOTE: The last modified date needs to be updated before calling these methods
pub fn send_user_update(&self, ut: UpdateType, user: &User) {
let data = create_update(
vec![
("UserId".into(), user.uuid.clone().into()),
("Date".into(), serialize_date(user.updated_at)),
],
ut,
);
self.send_update(&user.uuid, &data).ok();
}
pub fn send_folder_update(&self, ut: UpdateType, folder: &Folder) {
let data = create_update(
vec![
("Id".into(), folder.uuid.clone().into()),
("UserId".into(), folder.user_uuid.clone().into()),
("RevisionDate".into(), serialize_date(folder.updated_at)),
],
ut,
);
self.send_update(&folder.user_uuid, &data).ok();
}
pub fn send_cipher_update(&self, ut: UpdateType, cipher: &Cipher, user_uuids: &[String]) {
let user_uuid = convert_option(cipher.user_uuid.clone());
let org_uuid = convert_option(cipher.organization_uuid.clone());
let data = create_update(
vec![
("Id".into(), cipher.uuid.clone().into()),
("UserId".into(), user_uuid),
("OrganizationId".into(), org_uuid),
("CollectionIds".into(), Value::Nil),
("RevisionDate".into(), serialize_date(cipher.updated_at)),
],
ut,
);
for uuid in user_uuids {
self.send_update(&uuid, &data).ok();
}
}
}
/* Message Structure
[
1, // MessageType.Invocation
{}, // Headers
null, // InvocationId
"ReceiveMessage", // Target
[ // Arguments
{
"ContextId": "app_id",
"Type": ut as i32,
"Payload": {}
}
]
]
*/
fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType) -> Vec<u8> {
use rmpv::Value as V;
let value = V::Array(vec![
1.into(),
V::Array(vec![]),
V::Nil,
"ReceiveMessage".into(),
V::Array(vec![V::Map(vec![
("ContextId".into(), "app_id".into()),
("Type".into(), (ut as i32).into()),
("Payload".into(), payload.into()),
])]),
]);
serialize(value)
}
fn create_ping() -> Vec<u8> {
serialize(Value::Array(vec![6.into()]))
}
#[allow(dead_code)]
#[derive(PartialEq)]
pub enum UpdateType {
CipherUpdate = 0,
CipherCreate = 1,
LoginDelete = 2,
FolderDelete = 3,
Ciphers = 4,
Vault = 5,
OrgKeys = 6,
FolderCreate = 7,
FolderUpdate = 8,
CipherDelete = 9,
SyncSettings = 10,
LogOut = 11,
None = 100,
}
use rocket::State;
pub type Notify<'a> = State<'a, WebSocketUsers>;
pub fn start_notification_server() -> WebSocketUsers {
let factory = WSFactory::init();
let users = factory.users.clone();
if CONFIG.websocket_enabled() {
thread::spawn(move || {
WebSocket::new(factory)
.unwrap()
.listen((CONFIG.websocket_address().as_str(), CONFIG.websocket_port()))
.unwrap();
});
}
users
}

View File

@@ -1,79 +1,63 @@
use std::io;
use std::path::{Path, PathBuf};
use rocket::request::Request;
use rocket::response::{self, NamedFile, Responder};
use rocket::http::ContentType;
use rocket::response::content::Content;
use rocket::http::{ContentType, Status};
use rocket::response::NamedFile;
use rocket::Route;
use rocket_contrib::{Json, Value};
use rocket_contrib::json::Json;
use serde_json::Value;
use CONFIG;
use crate::util::Cached;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
if CONFIG.web_vault_enabled {
if CONFIG.web_vault_enabled() {
routes![web_index, app_id, web_files, attachments, alive]
} else {
routes![attachments, alive]
}
}
// TODO: Might want to use in memory cache: https://github.com/hgzimmerman/rocket-file-cache
#[get("/")]
fn web_index() -> WebHeaders<io::Result<NamedFile>> {
web_files("index.html".into())
fn web_index() -> Cached<io::Result<NamedFile>> {
Cached::short(NamedFile::open(
Path::new(&CONFIG.web_vault_folder()).join("index.html"),
))
}
#[get("/app-id.json")]
fn app_id() -> WebHeaders<Content<Json<Value>>> {
fn app_id() -> Cached<Content<Json<Value>>> {
let content_type = ContentType::new("application", "fido.trusted-apps+json");
WebHeaders(Content(content_type, Json(json!({
"trustedFacets": [
{
"version": { "major": 1, "minor": 0 },
"ids": [
&CONFIG.domain,
"ios:bundle-id:com.8bit.bitwarden",
"android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ]
}]
}))))
Cached::long(Content(
content_type,
Json(json!({
"trustedFacets": [
{
"version": { "major": 1, "minor": 0 },
"ids": [
&CONFIG.domain(),
"ios:bundle-id:com.8bit.bitwarden",
"android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ]
}]
})),
))
}
#[get("/<p..>", rank = 1)] // Only match this if the other routes don't match
fn web_files(p: PathBuf) -> WebHeaders<io::Result<NamedFile>> {
WebHeaders(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p)))
}
struct WebHeaders<R>(R);
impl<'r, R: Responder<'r>> Responder<'r> for WebHeaders<R> {
fn respond_to(self, req: &Request) -> response::Result<'r> {
match self.0.respond_to(req) {
Ok(mut res) => {
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");
res.set_raw_header("X-XSS-Protection", "1; mode=block");
Ok(res)
},
Err(_) => {
Err(Status::NotFound)
}
}
}
#[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)))
}
#[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")]
fn alive() -> Json<String> {
use util::format_date;
use crate::util::format_date;
use chrono::Utc;
Json(format_date(&Utc::now().naive_utc()))

View File

@@ -1,64 +1,74 @@
///
/// JWT Handling
///
//
// JWT Handling
//
use crate::util::read_file;
use chrono::{Duration, Utc};
use util::read_file;
use chrono::Duration;
use jwt;
use jsonwebtoken::{self, Algorithm, Header};
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use CONFIG;
use crate::error::{Error, MapResult};
use crate::CONFIG;
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
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: jwt::Header = jwt::Header::new(JWT_ALGORITHM);
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
static ref JWT_HEADER: Header = Header::new(JWT_ALGORITHM);
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),
};
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
match jsonwebtoken::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
Ok(token) => token,
Err(e) => panic!("Error encoding jwt {}", e)
Err(e) => panic!("Error encoding jwt {}", e),
}
}
pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
let validation = jwt::Validation {
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: 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],
};
match jwt::decode(token, &PUBLIC_RSA_KEY, &validation) {
Ok(decoded) => Ok(decoded.claims),
Err(msg) => {
println!("Error validating jwt - {:#?}", msg);
Err(msg.to_string())
}
}
let token = token.replace(char::is_whitespace, "");
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation)
.map(|d| d.claims)
.map_res("Error decoding JWT")
}
pub fn decode_login(token: &str) -> Result<LoginJWTClaims, Error> {
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
}
pub fn decode_invite(token: &str) -> Result<InviteJWTClaims, Error> {
decode_jwt(token, JWT_INVITE_ISSUER.to_string())
}
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
@@ -76,6 +86,7 @@ pub struct JWTClaims {
pub orgowner: Vec<String>,
pub orgadmin: Vec<String>,
pub orguser: Vec<String>,
pub orgmanager: Vec<String>,
// user security_stamp
pub sstamp: String,
@@ -87,15 +98,73 @@ pub struct JWTClaims {
pub amr: Vec<String>,
}
///
/// Bearer token authentication
///
#[derive(Debug, Serialize, Deserialize)]
pub struct InviteJWTClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: String,
pub email: String,
pub org_id: Option<String>,
pub user_org_id: Option<String>,
pub invited_by_email: Option<String>,
}
pub fn generate_invite_claims(
uuid: String,
email: String,
org_id: Option<String>,
org_user_id: Option<String>,
invited_by_email: Option<String>,
) -> InviteJWTClaims {
let time_now = Utc::now().naive_utc();
InviteJWTClaims {
nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_INVITE_ISSUER.to_string(),
sub: uuid.clone(),
email: email.clone(),
org_id: org_id.clone(),
user_org_id: org_user_id.clone(),
invited_by_email: invited_by_email.clone(),
}
}
#[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
//
use rocket::request::{self, FromRequest, Request};
use rocket::Outcome;
use rocket::request::{self, Request, FromRequest};
use db::DbConn;
use db::models::{User, UserOrganization, UserOrgType, UserOrgStatus, Device};
use crate::db::models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization};
use crate::db::DbConn;
pub struct Headers {
pub host: String,
@@ -110,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 {
@@ -138,20 +207,18 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
};
// 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")
let access_token: &str = match 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"),
};
// 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")
Err(_) => err_handler!("Invalid claim"),
};
let device_uuid = claims.device;
@@ -159,17 +226,17 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
let conn = match request.guard::<DbConn>() {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB")
_ => err_handler!("Error getting DB"),
};
let device = match Device::find_by_uuid(&device_uuid, &conn) {
Some(device) => device,
None => err_handler!("Invalid device id")
None => err_handler!("Invalid device id"),
};
let user = match User::find_by_uuid(&user_uuid, &conn) {
Some(user) => user,
None => err_handler!("Device has no user associated")
None => err_handler!("Device has no user associated"),
};
if user.security_stamp != claims.sstamp {
@@ -184,7 +251,7 @@ pub struct OrgHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub org_user_type: i32,
pub org_user_type: UserOrgType,
}
impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
@@ -192,19 +259,19 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
match request.guard::<Headers>() {
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
// org_id is expected to be the first dynamic param
match request.get_param::<String>(0) {
Err(_) => err_handler!("Error getting the organization id"),
Ok(org_id) => {
// org_id is expected to be the second param ("/organizations/<org_id>")
match request.get_param::<String>(1) {
Some(Ok(org_id)) => {
let conn = match request.guard::<DbConn>() {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB")
_ => err_handler!("Error getting DB"),
};
let org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
let user = headers.user;
let org_user = match UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn) {
Some(user) => {
if user.status == UserOrgStatus::Confirmed as i32 {
user
@@ -212,16 +279,24 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
err_handler!("The current user isn't confirmed member of the organization")
}
}
None => err_handler!("The current user isn't member of the organization")
None => err_handler!("The current user isn't member of the organization"),
};
Outcome::Success(Self{
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
org_user_type: org_user.type_,
user,
org_user_type: {
if let Some(org_usr_type) = UserOrgType::from_i32(org_user.type_) {
org_usr_type
} else {
// This should only happen if the DB is corrupted
err_handler!("Unknown user type in the database")
}
},
})
}
_ => err_handler!("Error getting the organization id"),
}
}
}
@@ -232,7 +307,7 @@ pub struct AdminHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub org_user_type: i32,
pub org_user_type: UserOrgType,
}
impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
@@ -240,18 +315,18 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
match request.guard::<OrgHeaders>() {
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
if headers.org_user_type > UserOrgType::Admin as i32 {
err_handler!("You need to be Admin or Owner to call this endpoint")
} else {
Outcome::Success(Self{
if headers.org_user_type >= UserOrgType::Admin {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
org_user_type: headers.org_user_type,
})
} else {
err_handler!("You need to be Admin or Owner to call this endpoint")
}
}
}
@@ -269,19 +344,41 @@ impl<'a, 'r> FromRequest<'a, 'r> for OwnerHeaders {
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
match request.guard::<OrgHeaders>() {
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
if headers.org_user_type > UserOrgType::Owner as i32 {
err_handler!("You need to be Owner to call this endpoint")
} else {
Outcome::Success(Self{
if headers.org_user_type == UserOrgType::Owner {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
})
} else {
err_handler!("You need to be Owner to call this endpoint")
}
}
}
}
}
//
// Client IP address detection
//
use std::net::IpAddr;
pub struct ClientIp {
pub ip: IpAddr,
}
impl<'a, 'r> FromRequest<'a, 'r> for ClientIp {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
let ip = match request.client_ip() {
Some(addr) => addr,
None => "0.0.0.0".parse().unwrap(),
};
Outcome::Success(ClientIp { ip })
}
}

596
src/config.rs Normal file
View File

@@ -0,0 +1,596 @@
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 = {
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{}/config.json", data_folder))
};
}
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, show_overrides: bool) -> 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 show_overrides && !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;
/// Enable the log to output to Syslog
use_syslog: bool, false, def, false;
/// Log file path
log_file: String, false, option;
/// Log level
log_level: String, false, def, "Info".to_string();
/// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using bitwarden_rs on some exotic filesystems,
/// 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;
},
/// Global Duo settings (Note that users can override them)
duo: _enable_duo {
/// Enabled
_enable_duo: bool, true, def, false;
/// Integration Key
duo_ikey: String, true, option;
/// Secret Key
duo_skey: Pass, true, option;
/// Host
duo_host: String, true, option;
/// Application Key (generated automatically)
_duo_akey: Pass, false, 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.duo_host.is_some() || cfg.duo_ikey.is_some() || cfg.duo_skey.is_some())
&& !(cfg.duo_host.is_some() && cfg.duo_ikey.is_some() && cfg.duo_skey.is_some())
{
err!("All Duo options need to be set for global Duo support")
}
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, true);
// 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, false).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 update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = {
let usr = &self.inner.read().unwrap()._usr;
usr.merge(&other, false)
};
self.update_config(builder)
}
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 get_duo_akey(&self) -> String {
if let Some(akey) = self._duo_akey() {
akey
} else {
let akey = crate::crypto::get_random_64();
let akey_s = data_encoding::BASE64.encode(&akey);
// Save the new value
let mut builder = ConfigBuilder::default();
builder._duo_akey = Some(akey_s.clone());
self.update_config_partial(builder).ok();
akey_s
}
}
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

@@ -1,8 +1,8 @@
///
/// PBKDF2 derivation
///
//
// PBKDF2 derivation
//
use ring::{digest, pbkdf2};
use ring::{digest, hmac, pbkdf2};
static DIGEST_ALG: &digest::Algorithm = &digest::SHA256;
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
@@ -19,9 +19,21 @@ pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterati
pbkdf2::verify(DIGEST_ALG, iterations, salt, secret, previous).is_ok()
}
///
/// Random values
///
//
// HMAC
//
pub fn hmac_sign(key: &str, data: &str) -> String {
use data_encoding::HEXLOWER;
let key = hmac::SigningKey::new(&digest::SHA1, key.as_bytes());
let signature = hmac::sign(&key, data.as_bytes());
HEXLOWER.encode(signature.as_ref())
}
//
// Random values
//
pub fn get_random_64() -> Vec<u8> {
get_random(vec![0u8; 64])
@@ -30,7 +42,18 @@ pub fn get_random_64() -> Vec<u8> {
pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
use ring::rand::{SecureRandom, SystemRandom};
SystemRandom::new().fill(&mut array).expect("Error generating random values");
SystemRandom::new()
.fill(&mut array)
.expect("Error generating random values");
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

@@ -1,15 +1,15 @@
use std::ops::Deref;
use diesel::{Connection as DieselConnection, ConnectionError};
use diesel::sqlite::SqliteConnection;
use diesel::r2d2;
use diesel::r2d2::ConnectionManager;
use diesel::sqlite::SqliteConnection;
use diesel::{Connection as DieselConnection, ConnectionError};
use rocket::http::Status;
use rocket::request::{self, FromRequest};
use rocket::{Outcome, Request, State};
use CONFIG;
use crate::CONFIG;
/// An alias to the database connection used
type Connection = SqliteConnection;
@@ -20,20 +20,18 @@ type Pool = r2d2::Pool<ConnectionManager<Connection>>;
/// Connection request guard type: a wrapper around an r2d2 pooled connection.
pub struct DbConn(pub r2d2::PooledConnection<ConnectionManager<Connection>>);
pub mod schema;
pub mod models;
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")
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
@@ -46,7 +44,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
let pool = request.guard::<State<Pool>>()?;
match pool.get() {
Ok(conn) => Outcome::Success(DbConn(conn)),
Err(_) => Outcome::Failure((Status::ServiceUnavailable, ()))
Err(_) => Outcome::Failure((Status::ServiceUnavailable, ())),
}
}
}

View File

@@ -1,7 +1,7 @@
use serde_json::Value as JsonValue;
use serde_json::Value;
use super::Cipher;
use CONFIG;
use crate::CONFIG;
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
#[table_name = "attachments"]
@@ -12,6 +12,7 @@ pub struct Attachment {
pub cipher_uuid: String,
pub file_name: String,
pub file_size: i32,
pub key: Option<String>,
}
/// Local methods
@@ -22,15 +23,16 @@ impl Attachment {
cipher_uuid,
file_name,
file_size,
key: None,
}
}
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) -> JsonValue {
use util::get_display_size;
pub fn to_json(&self, host: &str) -> Value {
use crate::util::get_display_size;
let web_path = format!("{}/attachments/{}/{}", host, self.cipher_uuid, self.id);
let display_size = get_display_size(self.file_size);
@@ -41,78 +43,67 @@ impl Attachment {
"FileName": self.file_name,
"Size": self.file_size.to_string(),
"SizeName": display_size,
"Key": self.key,
"Object": "attachment"
})
}
}
use crate::db::schema::attachments;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::attachments;
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Attachment {
pub fn save(&self, conn: &DbConn) -> QueryResult<()> {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
diesel::replace_into(attachments::table)
.values(self)
.execute(&**conn)
.and(Ok(()))
.map_res("Error saving attachment")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
use util;
use std::{thread, time};
pub fn delete(self, conn: &DbConn) -> EmptyResult {
crate::util::retry(
|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))).execute(&**conn),
10,
)
.map_res("Error deleting attachment")?;
let mut retries = 10;
loop {
match diesel::delete(
attachments::table.filter(
attachments::id.eq(&self.id)
)
).execute(&**conn) {
Ok(_) => break,
Err(err) => {
if retries < 1 {
println!("ERROR: Failed with 10 retries");
return Err(err)
} else {
retries = retries - 1;
println!("Had to retry! Retries left: {}", retries);
thread::sleep(time::Duration::from_millis(500));
continue
}
}
}
}
util::delete_file(&self.get_file_path());
crate::util::delete_file(&self.get_file_path())?;
Ok(())
}
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> QueryResult<()> {
for attachement in Attachment::find_by_cipher(&cipher_uuid, &conn) {
attachement.delete(&conn)?;
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> EmptyResult {
for attachment in Attachment::find_by_cipher(&cipher_uuid, &conn) {
attachment.delete(&conn)?;
}
Ok(())
}
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).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_cipher(cipher_uuid: &str, conn: &DbConn) -> Vec<Self> {
attachments::table
.filter(attachments::cipher_uuid.eq(cipher_uuid))
.load::<Self>(&**conn).expect("Error loading attachments")
.load::<Self>(&**conn)
.expect("Error loading attachments")
}
pub fn find_by_ciphers(cipher_uuids: Vec<String>, conn: &DbConn) -> Vec<Self> {
attachments::table
.filter(attachments::cipher_uuid.eq_any(cipher_uuids))
.load::<Self>(&**conn).expect("Error loading attachments")
.load::<Self>(&**conn)
.expect("Error loading attachments")
}
}

View File

@@ -1,9 +1,9 @@
use chrono::{NaiveDateTime, Utc};
use serde_json::Value as JsonValue;
use serde_json::Value;
use uuid::Uuid;
use super::{User, Organization, Attachment, FolderCipher, CollectionCipher, UserOrganization, UserOrgType, UserOrgStatus};
use super::{
Attachment, CollectionCipher, FolderCipher, Organization, User, UserOrgStatus, UserOrgType, UserOrganization,
};
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
#[table_name = "ciphers"]
@@ -41,7 +41,7 @@ impl Cipher {
let now = Utc::now().naive_utc();
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
created_at: now,
updated_at: now,
@@ -61,30 +61,35 @@ impl Cipher {
}
}
use crate::db::schema::*;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::*;
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Cipher {
pub fn to_json(&self, host: &str, user_uuid: &str, conn: &DbConn) -> JsonValue {
use serde_json;
use util::format_date;
use super::Attachment;
pub fn to_json(&self, host: &str, user_uuid: &str, conn: &DbConn) -> Value {
use crate::util::format_date;
let attachments = Attachment::find_by_cipher(&self.uuid, conn);
let attachments_json: Vec<JsonValue> = attachments.iter().map(|c| c.to_json(host)).collect();
let attachments_json: Vec<Value> = attachments.iter().map(|c| c.to_json(host)).collect();
let fields_json: JsonValue = if let Some(ref fields) = self.fields {
let fields_json: Value = if let Some(ref fields) = self.fields {
serde_json::from_str(fields).unwrap()
} else { JsonValue::Null };
} else {
Value::Null
};
let password_history_json: JsonValue = if let Some(ref password_history) = self.password_history {
let password_history_json: Value = if let Some(ref password_history) = self.password_history {
serde_json::from_str(password_history).unwrap()
} else { JsonValue::Null };
} else {
Value::Null
};
let mut data_json: JsonValue = serde_json::from_str(&self.data).unwrap();
let mut data_json: Value = serde_json::from_str(&self.data).unwrap();
// TODO: ******* Backwards compat start **********
// To remove backwards compatibility, just remove this entire section
@@ -130,167 +135,169 @@ impl Cipher {
json_object
}
pub fn update_users_revision(&self, conn: &DbConn) {
pub fn update_users_revision(&self, conn: &DbConn) -> Vec<String> {
let mut user_uuids = Vec::new();
match self.user_uuid {
Some(ref user_uuid) => User::update_uuid_revision(&user_uuid, conn),
None => { // Belongs to Organization, need to update affected users
Some(ref user_uuid) => {
User::update_uuid_revision(&user_uuid, conn);
user_uuids.push(user_uuid.clone())
}
None => {
// Belongs to Organization, need to update affected users
if let Some(ref org_uuid) = self.organization_uuid {
UserOrganization::find_by_cipher_and_org(&self.uuid, &org_uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn)
});
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
user_uuids.push(user_org.user_uuid.clone())
});
}
}
};
user_uuids
}
pub fn save(&mut self, conn: &DbConn) -> bool {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
self.updated_at = Utc::now().naive_utc();
match diesel::replace_into(ciphers::table)
diesel::replace_into(ciphers::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
.execute(&**conn)
.map_res("Error saving cipher")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
pub fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
FolderCipher::delete_all_by_cipher(&self.uuid, &conn)?;
CollectionCipher::delete_all_by_cipher(&self.uuid, &conn)?;
Attachment::delete_all_by_cipher(&self.uuid, &conn)?;
diesel::delete(
ciphers::table.filter(
ciphers::uuid.eq(self.uuid)
)
).execute(&**conn).and(Ok(()))
diesel::delete(ciphers::table.filter(ciphers::uuid.eq(&self.uuid)))
.execute(&**conn)
.map_res("Error deleting cipher")
}
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> QueryResult<()> {
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
for cipher in Self::find_by_org(org_uuid, &conn) {
cipher.delete(&conn)?;
}
Ok(())
}
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> Result<(), &str> {
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).or(Err("Couldn't save folder setting"))
},
None => Ok(()) //nothing to do
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for cipher in Self::find_owned_by_user(user_uuid, &conn) {
cipher.delete(&conn)?;
}
Ok(())
}
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult {
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"),
},
Some(current_folder) => {
match folder_uuid {
Some(new_folder) => {
if current_folder == new_folder {
Ok(()) //nothing to do
} else {
self.update_users_revision(conn);
match FolderCipher::find_by_folder_and_cipher(&current_folder, &self.uuid, &conn) {
Some(current_folder) => {
current_folder.delete(&conn).or(Err("Failed removing old folder mapping"))
},
None => Ok(()) // Weird, but nothing to do
}.and_then(
|()| FolderCipher::new(&new_folder, &self.uuid)
.save(&conn).or(Err("Couldn't save folder setting"))
)
}
},
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).or(Err("Failed removing old folder mapping"))
},
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)
}
}
}
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.left_join(users_organizations::table.on(
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()).and(
users_organizations::user_uuid.eq(user_uuid)
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.left_join(
users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))),
)
))
.left_join(ciphers_collections::table)
.left_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
))
.filter(ciphers::user_uuid.eq(user_uuid).or( // Cipher owner
users_organizations::access_all.eq(true).or( // access_all in Organization
users_organizations::type_.le(UserOrgType::Admin as i32).or( // Org admin or owner
users_collections::user_uuid.eq(user_uuid).and(
users_collections::read_only.eq(false) //R/W access to collection
)
)
.left_join(ciphers_collections::table)
.left_join(
users_collections::table
.on(ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)),
)
))
.select(ciphers::all_columns)
.first::<Self>(&**conn).ok() {
Some(_) => true,
None => false
}
.filter(ciphers::user_uuid.eq(user_uuid).or(
// Cipher owner
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::type_.le(UserOrgType::Admin as i32).or(
// Org admin or owner
users_collections::user_uuid.eq(user_uuid).and(
users_collections::read_only.eq(false), //R/W access to collection
),
),
),
))
.select(ciphers::all_columns)
.first::<Self>(&**conn)
.ok()
.is_some()
}
pub fn is_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.left_join(users_organizations::table.on(
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()).and(
users_organizations::user_uuid.eq(user_uuid)
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.left_join(
users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))),
)
))
.left_join(ciphers_collections::table)
.left_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
))
.filter(ciphers::user_uuid.eq(user_uuid).or( // Cipher owner
users_organizations::access_all.eq(true).or( // access_all in Organization
users_organizations::type_.le(UserOrgType::Admin as i32).or( // Org admin or owner
users_collections::user_uuid.eq(user_uuid) // Access to Collection
)
.left_join(ciphers_collections::table)
.left_join(
users_collections::table
.on(ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)),
)
))
.select(ciphers::all_columns)
.first::<Self>(&**conn).ok() {
Some(_) => true,
None => false
}
.filter(ciphers::user_uuid.eq(user_uuid).or(
// Cipher owner
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::type_.le(UserOrgType::Admin as i32).or(
// Org admin or owner
users_collections::user_uuid.eq(user_uuid), // Access to Collection
),
),
))
.select(ciphers::all_columns)
.first::<Self>(&**conn)
.ok()
.is_some()
}
pub fn get_folder_uuid(&self, user_uuid: &str, conn: &DbConn) -> Option<String> {
folders_ciphers::table.inner_join(folders::table)
folders_ciphers::table
.inner_join(folders::table)
.filter(folders::user_uuid.eq(&user_uuid))
.filter(folders_ciphers::cipher_uuid.eq(&self.uuid))
.select(folders_ciphers::folder_uuid)
.first::<String>(&**conn).ok()
.first::<String>(&**conn)
.ok()
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
ciphers::table
.filter(ciphers::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
// Find all ciphers accesible to user
// Find all ciphers accessible to user
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
ciphers::table
.left_join(users_organizations::table.on(
@@ -300,14 +307,18 @@ impl Cipher {
)
)
))
.left_join(ciphers_collections::table)
.left_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
))
.left_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
))
.filter(ciphers::user_uuid.eq(user_uuid).or( // Cipher owner
users_organizations::access_all.eq(true).or( // access_all in Organization
users_organizations::type_.le(UserOrgType::Admin as i32).or( // Org admin or owner
users_collections::user_uuid.eq(user_uuid) // Access to Collection
users_collections::user_uuid.eq(user_uuid).and( // Access to Collection
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
)
)
)
))
@@ -347,7 +358,9 @@ impl Cipher {
)
))
.left_join(users_collections::table.on(
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid)
users_collections::collection_uuid.eq(ciphers_collections::collection_uuid).and(
users_collections::user_uuid.eq(user_id)
)
))
.filter(ciphers_collections::cipher_uuid.eq(&self.uuid))
.filter(users_collections::user_uuid.eq(user_id).or( // User has access to collection
@@ -356,6 +369,6 @@ impl Cipher {
)
))
.select(ciphers_collections::collection_uuid)
.load::<String>(&**conn).unwrap_or(vec![])
.load::<String>(&**conn).unwrap_or_default()
}
}

View File

@@ -1,8 +1,6 @@
use serde_json::Value as JsonValue;
use serde_json::Value;
use uuid::Uuid;
use super::{Organization, UserOrganization, UserOrgType, UserOrgStatus};
use super::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
#[table_name = "collections"]
@@ -18,14 +16,14 @@ pub struct Collection {
impl Collection {
pub fn new(org_uuid: String, name: String) -> Self {
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
org_uuid,
name,
}
}
pub fn to_json(&self) -> JsonValue {
pub fn to_json(&self) -> Value {
json!({
"Id": self.uuid,
"OrganizationId": self.org_uuid,
@@ -35,84 +33,101 @@ impl Collection {
}
}
use crate::db::schema::*;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::*;
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Collection {
pub fn save(&mut self, conn: &DbConn) -> bool {
match diesel::replace_into(collections::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
pub fn save(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
diesel::replace_into(collections::table)
.values(self)
.execute(&**conn)
.map_res("Error saving collection")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
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)?;
diesel::delete(
collections::table.filter(
collections::uuid.eq(self.uuid)
)
).execute(&**conn).and(Ok(()))
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error deleting collection")
}
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> QueryResult<()> {
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
for collection in Self::find_by_organization(org_uuid, &conn) {
collection.delete(&conn)?;
}
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))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_user_uuid(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
let mut all_access_collections = users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
.filter(users_organizations::access_all.eq(true))
.inner_join(collections::table.on(collections::org_uuid.eq(users_organizations::org_uuid)))
.select(collections::all_columns)
.load::<Self>(&**conn).expect("Error loading collections");
let mut assigned_collections = users_collections::table.inner_join(collections::table)
.left_join(users_organizations::table.on(
users_collections::user_uuid.eq(users_organizations::user_uuid)
))
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
.select(collections::all_columns)
.load::<Self>(&**conn).expect("Error loading collections");
all_access_collections.append(&mut assigned_collections);
all_access_collections
collections::table
.left_join(users_collections::table.on(
users_collections::collection_uuid.eq(collections::uuid).and(
users_collections::user_uuid.eq(user_uuid)
)
))
.left_join(users_organizations::table.on(
collections::org_uuid.eq(users_organizations::org_uuid).and(
users_organizations::user_uuid.eq(user_uuid)
)
))
.filter(
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
)
.filter(
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
users_organizations::access_all.eq(true) // access_all in Organization
)
).select(collections::all_columns)
.load::<Self>(&**conn).expect("Error loading collections")
}
pub fn find_by_organization_and_user_uuid(org_uuid: &str, user_uuid: &str, conn: &DbConn) -> Vec<Self> {
Self::find_by_user_uuid(user_uuid, conn).into_iter().filter(|c| c.org_uuid == org_uuid).collect()
Self::find_by_user_uuid(user_uuid, conn)
.into_iter()
.filter(|c| c.org_uuid == org_uuid)
.collect()
}
pub fn find_by_organization(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
collections::table
.filter(collections::org_uuid.eq(org_uuid))
.load::<Self>(&**conn).expect("Error loading collections")
.load::<Self>(&**conn)
.expect("Error loading collections")
}
pub fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &DbConn) -> Option<Self> {
collections::table
.filter(collections::uuid.eq(uuid))
.filter(collections::org_uuid.eq(org_uuid))
.select(collections::all_columns)
.first::<Self>(&**conn).ok()
.filter(collections::uuid.eq(uuid))
.filter(collections::org_uuid.eq(org_uuid))
.select(collections::all_columns)
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_uuid_and_user(uuid: &str, user_uuid: &str, conn: &DbConn) -> Option<Self> {
@@ -142,19 +157,19 @@ impl Collection {
match UserOrganization::find_by_user_and_org(&user_uuid, &self.org_uuid, &conn) {
None => false, // Not in Org
Some(user_org) => {
if user_org.access_all {
true
} else {
match users_collections::table.inner_join(collections::table)
.filter(users_collections::collection_uuid.eq(&self.uuid))
.filter(users_collections::user_uuid.eq(&user_uuid))
.filter(users_collections::read_only.eq(false))
.select(collections::all_columns)
.first::<Self>(&**conn).ok() {
None => false, // Read only or no access to collection
Some(_) => true,
}
}
if user_org.access_all {
true
} else {
users_collections::table
.inner_join(collections::table)
.filter(users_collections::collection_uuid.eq(&self.uuid))
.filter(users_collections::user_uuid.eq(&user_uuid))
.filter(users_collections::read_only.eq(false))
.select(collections::all_columns)
.first::<Self>(&**conn)
.ok()
.is_some() // Read only or no access to collection
}
}
}
}
@@ -181,62 +196,70 @@ impl CollectionUser {
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
.filter(collections::org_uuid.eq(org_uuid))
.select(users_collections::all_columns)
.load::<Self>(&**conn).expect("Error loading users_collections")
.load::<Self>(&**conn)
.expect("Error loading users_collections")
}
pub fn save(user_uuid: &str, collection_uuid: &str, read_only:bool, conn: &DbConn) -> QueryResult<()> {
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&user_uuid, conn);
diesel::replace_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
)).execute(&**conn).and(Ok(()))
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
))
.execute(&**conn)
.map_res("Error adding user to collection")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
pub fn delete(self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
diesel::delete(users_collections::table
.filter(users_collections::user_uuid.eq(&self.user_uuid))
.filter(users_collections::collection_uuid.eq(&self.collection_uuid)))
.execute(&**conn).and(Ok(()))
diesel::delete(
users_collections::table
.filter(users_collections::user_uuid.eq(&self.user_uuid))
.filter(users_collections::collection_uuid.eq(&self.collection_uuid)),
)
.execute(&**conn)
.map_res("Error removing user from collection")
}
pub fn find_by_collection(collection_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_collections::table
.filter(users_collections::collection_uuid.eq(collection_uuid))
.select(users_collections::all_columns)
.load::<Self>(&**conn).expect("Error loading users_collections")
.filter(users_collections::collection_uuid.eq(collection_uuid))
.select(users_collections::all_columns)
.load::<Self>(&**conn)
.expect("Error loading users_collections")
}
pub fn find_by_collection_and_user(collection_uuid: &str, user_uuid: &str, conn: &DbConn) -> Option<Self> {
users_collections::table
.filter(users_collections::collection_uuid.eq(collection_uuid))
.filter(users_collections::user_uuid.eq(user_uuid))
.select(users_collections::all_columns)
.first::<Self>(&**conn).ok()
}
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> QueryResult<()> {
CollectionUser::find_by_collection(&collection_uuid, conn)
.iter()
.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).and(Ok(()))
.filter(users_collections::user_uuid.eq(user_uuid))
.select(users_collections::all_columns)
.first::<Self>(&**conn)
.ok()
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> {
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);
});
diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid)))
.execute(&**conn)
.map_res("Error deleting users from collection")
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&user_uuid, conn);
diesel::delete(users_collections::table
.filter(users_collections::user_uuid.eq(user_uuid))
).execute(&**conn).and(Ok(()))
diesel::delete(users_collections::table.filter(users_collections::user_uuid.eq(user_uuid)))
.execute(&**conn)
.map_res("Error removing user from collections")
}
}
@@ -254,36 +277,43 @@ pub struct CollectionCipher {
/// Database methods
impl CollectionCipher {
pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> bool {
match diesel::replace_into(ciphers_collections::table)
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),
ciphers_collections::collection_uuid.eq(collection_uuid),
)).execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
))
.execute(&**conn)
.map_res("Error adding cipher to collection")
}
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))
.filter(ciphers_collections::collection_uuid.eq(collection_uuid)),
)
.execute(&**conn)
.map_res("Error deleting cipher from collection")
}
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)))
.execute(&**conn)
.map_res("Error removing cipher from collections")
}
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid)))
.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);
}
}
pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> bool {
match diesel::delete(ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
.filter(ciphers_collections::collection_uuid.eq(collection_uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
}
}
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> QueryResult<()> {
diesel::delete(ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
).execute(&**conn).and(Ok(()))
}
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> QueryResult<()> {
diesel::delete(ciphers_collections::table
.filter(ciphers_collections::collection_uuid.eq(collection_uuid))
).execute(&**conn).and(Ok(()))
}
}

View File

@@ -44,8 +44,8 @@ impl Device {
}
pub fn refresh_twofactor_remember(&mut self) -> String {
use crate::crypto;
use data_encoding::BASE64;
use crypto;
let twofactor_remember = BASE64.encode(&crypto::get_random(vec![0u8; 180]));
self.twofactor_remember = Some(twofactor_remember.clone());
@@ -57,12 +57,11 @@ impl Device {
self.twofactor_remember = None;
}
pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec<super::UserOrganization>) -> (String, i64) {
// If there is no refresh token, we create one
if self.refresh_token.is_empty() {
use crate::crypto;
use data_encoding::BASE64URL;
use crypto;
self.refresh_token = BASE64URL.encode(&crypto::get_random_64());
}
@@ -71,18 +70,18 @@ impl Device {
let time_now = Utc::now().naive_utc();
self.updated_at = time_now;
let orgowner: Vec<_> = orgs.iter().filter(|o| o.type_ == 0).map(|o| o.org_uuid.clone()).collect();
let orgadmin: Vec<_> = orgs.iter().filter(|o| o.type_ == 1).map(|o| o.org_uuid.clone()).collect();
let orguser: Vec<_> = orgs.iter().filter(|o| o.type_ == 2).map(|o| o.org_uuid.clone()).collect();
let orgmanager: Vec<_> = orgs.iter().filter(|o| o.type_ == 3).map(|o| o.org_uuid.clone()).collect();
// Create the JWT claims struct, to send to the client
use 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,
@@ -93,6 +92,7 @@ impl Device {
orgowner,
orgadmin,
orguser,
orgmanager,
sstamp: user.security_stamp.to_string(),
device: self.uuid.to_string(),
@@ -100,53 +100,61 @@ impl Device {
amr: vec!["Application".into()],
};
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
}
}
use crate::db::schema::devices;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::devices;
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Device {
pub fn save(&mut self, conn: &DbConn) -> bool {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc();
match diesel::replace_into(devices::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
crate::util::retry(
|| diesel::replace_into(devices::table).values(&*self).execute(&**conn),
10,
)
.map_res("Error saving device")
}
pub fn delete(self, conn: &DbConn) -> bool {
match diesel::delete(devices::table.filter(
devices::uuid.eq(self.uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
pub fn delete(self, conn: &DbConn) -> EmptyResult {
diesel::delete(devices::table.filter(devices::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error removing device")
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for device in Self::find_by_user(user_uuid, &conn) {
device.delete(&conn)?;
}
Ok(())
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
devices::table
.filter(devices::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Self> {
devices::table
.filter(devices::refresh_token.eq(refresh_token))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
devices::table
.filter(devices::user_uuid.eq(user_uuid))
.load::<Self>(&**conn).expect("Error loading devices")
.load::<Self>(&**conn)
.expect("Error loading devices")
}
}

View File

@@ -1,9 +1,7 @@
use chrono::{NaiveDateTime, Utc};
use serde_json::Value as JsonValue;
use serde_json::Value;
use uuid::Uuid;
use super::{User, Cipher};
use super::{Cipher, User};
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
#[table_name = "folders"]
@@ -33,7 +31,7 @@ impl Folder {
let now = Utc::now().naive_utc();
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
created_at: now,
updated_at: now,
@@ -42,8 +40,8 @@ impl Folder {
}
}
pub fn to_json(&self) -> JsonValue {
use util::format_date;
pub fn to_json(&self) -> Value {
use crate::util::format_date;
json!({
"Id": self.uuid,
@@ -63,85 +61,99 @@ impl FolderCipher {
}
}
use crate::db::schema::{folders, folders_ciphers};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::{folders, folders_ciphers};
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Folder {
pub fn save(&mut self, conn: &DbConn) -> bool {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
self.updated_at = Utc::now().naive_utc();
match diesel::replace_into(folders::table)
diesel::replace_into(folders::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
.execute(&**conn)
.map_res("Error saving folder")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
pub fn delete(&self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
FolderCipher::delete_all_by_folder(&self.uuid, &conn)?;
diesel::delete(
folders::table.filter(
folders::uuid.eq(self.uuid)
)
).execute(&**conn).and(Ok(()))
diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid)))
.execute(&**conn)
.map_res("Error deleting folder")
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for folder in Self::find_by_user(user_uuid, &conn) {
folder.delete(&conn)?;
}
Ok(())
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
folders::table
.filter(folders::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
folders::table
.filter(folders::user_uuid.eq(user_uuid))
.load::<Self>(&**conn).expect("Error loading folders")
.load::<Self>(&**conn)
.expect("Error loading folders")
}
}
impl FolderCipher {
pub fn save(&self, conn: &DbConn) -> QueryResult<()> {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
diesel::replace_into(folders_ciphers::table)
.values(&*self)
.execute(&**conn).and(Ok(()))
.values(&*self)
.execute(&**conn)
.map_res("Error adding cipher to folder")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
diesel::delete(folders_ciphers::table
.filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid))
.filter(folders_ciphers::folder_uuid.eq(self.folder_uuid))
).execute(&**conn).and(Ok(()))
pub fn delete(self, conn: &DbConn) -> EmptyResult {
diesel::delete(
folders_ciphers::table
.filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid))
.filter(folders_ciphers::folder_uuid.eq(self.folder_uuid)),
)
.execute(&**conn)
.map_res("Error removing cipher from folder")
}
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> QueryResult<()> {
diesel::delete(folders_ciphers::table
.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))
).execute(&**conn).and(Ok(()))
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)))
.execute(&**conn)
.map_res("Error removing cipher from folders")
}
pub fn delete_all_by_folder(folder_uuid: &str, conn: &DbConn) -> QueryResult<()> {
diesel::delete(folders_ciphers::table
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
).execute(&**conn).and(Ok(()))
pub fn delete_all_by_folder(folder_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid)))
.execute(&**conn)
.map_res("Error removing ciphers from folder")
}
pub fn find_by_folder_and_cipher(folder_uuid: &str, cipher_uuid: &str, conn: &DbConn) -> Option<Self> {
folders_ciphers::table
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_folder(folder_uuid: &str, conn: &DbConn) -> Vec<Self> {
folders_ciphers::table
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))
.load::<Self>(&**conn).expect("Error loading folders")
.load::<Self>(&**conn)
.expect("Error loading folders")
}
}

View File

@@ -10,10 +10,10 @@ mod two_factor;
pub use self::attachment::Attachment;
pub use self::cipher::Cipher;
pub use self::collection::{Collection, CollectionCipher, CollectionUser};
pub use self::device::Device;
pub use self::folder::{Folder, FolderCipher};
pub use self::user::{User, Invitation};
pub use self::organization::Organization;
pub use self::organization::{UserOrganization, UserOrgStatus, UserOrgType};
pub use self::collection::{Collection, CollectionUser, CollectionCipher};
pub use self::organization::{UserOrgStatus, UserOrgType, UserOrganization};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::user::{Invitation, User};

View File

@@ -1,7 +1,7 @@
use serde_json::Value as JsonValue;
use serde_json::Value;
use std::cmp::Ordering;
use uuid::Uuid;
use super::{User, CollectionUser};
use super::{CollectionUser, User};
#[derive(Debug, Identifiable, Queryable, Insertable)]
#[table_name = "organizations"]
@@ -32,10 +32,97 @@ pub enum UserOrgStatus {
Confirmed = 2,
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum UserOrgType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
}
impl Ord for UserOrgType {
fn cmp(&self, other: &UserOrgType) -> Ordering {
if self == other {
Ordering::Equal
} else {
match self {
UserOrgType::Owner => Ordering::Greater,
UserOrgType::Admin => match other {
UserOrgType::Owner => Ordering::Less,
_ => Ordering::Greater,
},
UserOrgType::Manager => match other {
UserOrgType::Owner | UserOrgType::Admin => Ordering::Less,
_ => Ordering::Greater,
},
UserOrgType::User => Ordering::Less,
}
}
}
}
impl PartialOrd for UserOrgType {
fn partial_cmp(&self, other: &UserOrgType) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq<i32> for UserOrgType {
fn eq(&self, other: &i32) -> bool {
*other == *self as i32
}
}
impl PartialOrd<i32> for UserOrgType {
fn partial_cmp(&self, other: &i32) -> Option<Ordering> {
if let Some(other) = Self::from_i32(*other) {
return Some(self.cmp(&other));
}
None
}
fn gt(&self, other: &i32) -> bool {
match self.partial_cmp(other) {
Some(Ordering::Less) | Some(Ordering::Equal) => false,
_ => true,
}
}
fn ge(&self, other: &i32) -> bool {
match self.partial_cmp(other) {
Some(Ordering::Less) => false,
_ => true,
}
}
}
impl PartialEq<UserOrgType> for i32 {
fn eq(&self, other: &UserOrgType) -> bool {
*self == *other as i32
}
}
impl PartialOrd<UserOrgType> for i32 {
fn partial_cmp(&self, other: &UserOrgType) -> Option<Ordering> {
if let Some(self_type) = UserOrgType::from_i32(*self) {
return Some(self_type.cmp(other));
}
None
}
fn lt(&self, other: &UserOrgType) -> bool {
match self.partial_cmp(other) {
Some(Ordering::Less) | None => true,
_ => false,
}
}
fn le(&self, other: &UserOrgType) -> bool {
match self.partial_cmp(other) {
Some(Ordering::Less) | Some(Ordering::Equal) | None => true,
_ => false,
}
}
}
impl UserOrgType {
@@ -44,6 +131,17 @@ impl UserOrgType {
"0" | "Owner" => Some(UserOrgType::Owner),
"1" | "Admin" => Some(UserOrgType::Admin),
"2" | "User" => Some(UserOrgType::User),
"3" | "Manager" => Some(UserOrgType::Manager),
_ => None,
}
}
pub fn from_i32(i: i32) -> Option<Self> {
match i {
0 => Some(UserOrgType::Owner),
1 => Some(UserOrgType::Admin),
2 => Some(UserOrgType::User),
3 => Some(UserOrgType::Manager),
_ => None,
}
}
@@ -53,20 +151,20 @@ impl UserOrgType {
impl Organization {
pub fn new(name: String, billing_email: String) -> Self {
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
name,
billing_email,
}
}
pub fn to_json(&self) -> JsonValue {
pub fn to_json(&self) -> Value {
json!({
"Id": self.uuid,
"Name": self.name,
"Seats": 10,
"MaxCollections": 10,
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
"Use2fa": true,
"UseDirectory": false,
"UseEvents": false,
@@ -83,7 +181,7 @@ impl Organization {
"BillingEmail": self.billing_email,
"Plan": "TeamsAnnually",
"PlanType": 5, // TeamsAnnually plan
"UsersGetPremium": true,
"Object": "organization",
})
}
@@ -92,7 +190,7 @@ impl Organization {
impl UserOrganization {
pub fn new(user_uuid: String, org_uuid: String) -> Self {
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
user_uuid,
org_uuid,
@@ -105,52 +203,51 @@ impl UserOrganization {
}
}
use crate::db::schema::{ciphers_collections, organizations, users_collections, users_organizations};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::{organizations, users_organizations, users_collections, ciphers_collections};
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl Organization {
pub fn save(&mut self, conn: &DbConn) -> bool {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
UserOrganization::find_by_org(&self.uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
match diesel::replace_into(organizations::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
diesel::replace_into(organizations::table)
.values(self)
.execute(&**conn)
.map_res("Error saving organization")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
pub fn delete(self, conn: &DbConn) -> EmptyResult {
use super::{Cipher, Collection};
Cipher::delete_all_by_organization(&self.uuid, &conn)?;
Collection::delete_all_by_organization(&self.uuid, &conn)?;
UserOrganization::delete_all_by_organization(&self.uuid, &conn)?;
diesel::delete(
organizations::table.filter(
organizations::uuid.eq(self.uuid)
)
).execute(&**conn).and(Ok(()))
diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error saving organization")
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
organizations::table
.filter(organizations::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
}
impl UserOrganization {
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
pub fn to_json(&self, conn: &DbConn) -> Value {
let org = Organization::find_by_uuid(&self.org_uuid, conn).unwrap();
json!({
@@ -158,6 +255,7 @@ impl UserOrganization {
"Name": org.name,
"Seats": 10,
"MaxCollections": 10,
"UsersGetPremium": true,
"Use2fa": true,
"UseDirectory": false,
@@ -177,7 +275,7 @@ impl UserOrganization {
})
}
pub fn to_json_user_details(&self, conn: &DbConn) -> JsonValue {
pub fn to_json_user_details(&self, conn: &DbConn) -> Value {
let user = User::find_by_uuid(&self.user_uuid, conn).unwrap();
json!({
@@ -194,27 +292,22 @@ impl UserOrganization {
})
}
pub fn to_json_collection_user_details(&self, read_only: &bool, conn: &DbConn) -> JsonValue {
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
})
}
pub fn to_json_details(&self, conn: &DbConn) -> JsonValue {
pub fn to_json_details(&self, conn: &DbConn) -> Value {
let coll_uuids = if self.access_all {
vec![] // If we have complete access, no need to fill the array
} else {
let collections = CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn);
collections.iter().map(|c| json!({"Id": c.collection_uuid, "ReadOnly": c.read_only})).collect()
collections
.iter()
.map(|c| json!({"Id": c.collection_uuid, "ReadOnly": c.read_only}))
.collect()
};
json!({
@@ -230,85 +323,102 @@ impl UserOrganization {
})
}
pub fn save(&mut self, conn: &DbConn) -> bool {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
match diesel::replace_into(users_organizations::table)
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
diesel::replace_into(users_organizations::table)
.values(self)
.execute(&**conn)
.map_res("Error adding user to organization")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
pub fn delete(self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn);
CollectionUser::delete_all_by_user(&self.user_uuid, &conn)?;
diesel::delete(
users_organizations::table.filter(
users_organizations::uuid.eq(self.uuid)
)
).execute(&**conn).and(Ok(()))
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error removing user from organization")
}
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> QueryResult<()> {
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
for user_org in Self::find_by_org(&org_uuid, &conn) {
user_org.delete(&conn)?;
}
Ok(())
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
for user_org in Self::find_any_state_by_user(&user_uuid, &conn) {
user_org.delete(&conn)?;
}
Ok(())
}
pub fn has_full_access(self) -> bool {
self.access_all || self.type_ < UserOrgType::User as i32
self.access_all || self.type_ >= UserOrgType::Admin
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
users_organizations::table
.filter(users_organizations::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_uuid_and_org(uuid: &str, org_uuid: &str, conn: &DbConn) -> Option<Self> {
users_organizations::table
.filter(users_organizations::uuid.eq(uuid))
.filter(users_organizations::org_uuid.eq(org_uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
.load::<Self>(&**conn).unwrap_or(vec![])
.load::<Self>(&**conn)
.unwrap_or_default()
}
pub fn find_invited_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(UserOrgStatus::Invited as i32))
.load::<Self>(&**conn).unwrap_or(vec![])
.load::<Self>(&**conn)
.unwrap_or_default()
}
pub fn find_any_state_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.load::<Self>(&**conn)
.unwrap_or_default()
}
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.load::<Self>(&**conn).expect("Error loading user organizations")
.load::<Self>(&**conn)
.expect("Error loading user organizations")
}
pub fn find_by_org_and_type(org_uuid: &str, type_: i32, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::type_.eq(type_))
.load::<Self>(&**conn).expect("Error loading user organizations")
.load::<Self>(&**conn)
.expect("Error loading user organizations")
}
pub fn find_by_user_and_org(user_uuid: &str, org_uuid: &str, conn: &DbConn) -> Option<Self> {
users_organizations::table
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.eq(org_uuid))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_cipher_and_org(cipher_uuid: &str, org_uuid: &str, conn: &DbConn) -> Vec<Self> {
@@ -330,6 +440,19 @@ impl UserOrganization {
.select(users_organizations::all_columns)
.load::<Self>(&**conn).expect("Error loading user organizations")
}
pub fn find_by_collection_and_org(collection_uuid: &str, org_uuid: &str, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.left_join(users_collections::table.on(
users_collections::user_uuid.eq(users_organizations::user_uuid)
))
.filter(
users_organizations::access_all.eq(true).or( // AccessAll..
users_collections::collection_uuid.eq(&collection_uuid) // ..or access to collection with cipher
)
)
.select(users_organizations::all_columns)
.load::<Self>(&**conn).expect("Error loading user organizations")
}
}

View File

@@ -1,6 +1,4 @@
use serde_json::Value as JsonValue;
use uuid::Uuid;
use serde_json::Value;
use super::User;
@@ -17,7 +15,7 @@ pub struct TwoFactor {
}
#[allow(dead_code)]
#[derive(FromPrimitive, ToPrimitive)]
#[derive(FromPrimitive)]
pub enum TwoFactorType {
Authenticator = 0,
Email = 1,
@@ -36,7 +34,7 @@ pub enum TwoFactorType {
impl TwoFactor {
pub fn new(user_uuid: String, type_: TwoFactorType, data: String) -> Self {
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
user_uuid,
type_: type_ as i32,
enabled: true,
@@ -44,22 +42,7 @@ 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) -> JsonValue {
pub fn to_json(&self) -> Value {
json!({
"Enabled": self.enabled,
"Key": "", // This key and value vary
@@ -67,7 +50,7 @@ impl TwoFactor {
})
}
pub fn to_json_list(&self) -> JsonValue {
pub fn to_json_list(&self) -> Value {
json!({
"Enabled": self.enabled,
"Type": self.type_,
@@ -76,37 +59,48 @@ impl TwoFactor {
}
}
use crate::db::schema::twofactor;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::twofactor;
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl TwoFactor {
pub fn save(&self, conn: &DbConn) -> QueryResult<usize> {
pub fn save(&self, conn: &DbConn) -> EmptyResult {
diesel::replace_into(twofactor::table)
.values(self)
.execute(&**conn)
.map_res("Error saving twofactor")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<usize> {
diesel::delete(
twofactor::table.filter(
twofactor::uuid.eq(self.uuid)
)
).execute(&**conn)
pub fn delete(self, conn: &DbConn) -> EmptyResult {
diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error deleting twofactor")
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid))
.load::<Self>(&**conn).expect("Error loading twofactor")
.filter(twofactor::type_.lt(1000)) // Filter implementation types
.load::<Self>(&**conn)
.expect("Error loading twofactor")
}
pub fn find_by_user_and_type(user_uuid: &str, type_: i32, conn: &DbConn) -> Option<Self> {
twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::type_.eq(type_))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid)))
.execute(&**conn)
.map_res("Error deleting twofactors")
}
}

View File

@@ -1,11 +1,8 @@
use chrono::{NaiveDateTime, Utc};
use serde_json::Value as JsonValue;
use uuid::Uuid;
use crypto;
use CONFIG;
use serde_json::Value;
use crate::crypto;
use crate::CONFIG;
#[derive(Debug, Identifiable, Queryable, Insertable)]
#[table_name = "users"]
@@ -35,19 +32,28 @@ pub struct User {
pub equivalent_domains: String,
pub excluded_globals: String,
pub client_kdf_type: i32,
pub client_kdf_iter: i32,
}
enum UserStatus {
Enabled = 0,
Invited = 1,
_Disabled = 2,
}
/// Local methods
impl User {
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = 0; // PBKDF2: 0
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 5_000;
pub fn new(mail: String) -> Self {
let now = Utc::now().naive_utc();
let email = mail.to_lowercase();
let iterations = CONFIG.password_iterations;
let salt = crypto::get_random_64();
Self {
uuid: Uuid::new_v4().to_string(),
uuid: crate::util::get_uuid(),
created_at: now,
updated_at: now,
name: email.clone(),
@@ -55,10 +61,10 @@ impl User {
key: String::new(),
password_hash: Vec::new(),
salt,
password_iterations: iterations,
salt: crypto::get_random_64(),
password_iterations: CONFIG.password_iterations(),
security_stamp: Uuid::new_v4().to_string(),
security_stamp: crate::util::get_uuid(),
password_hint: None,
private_key: None,
@@ -69,53 +75,63 @@ impl User {
equivalent_domains: "[]".to_string(),
excluded_globals: "[]".to_string(),
client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT,
client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT,
}
}
pub fn check_valid_password(&self, password: &str) -> bool {
crypto::verify_password_hash(password.as_bytes(),
&self.salt,
&self.password_hash,
self.password_iterations as u32)
crypto::verify_password_hash(
password.as_bytes(),
&self.salt,
&self.password_hash,
self.password_iterations as u32,
)
}
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
}
}
pub fn set_password(&mut self, password: &str) {
self.password_hash = crypto::hash_password(password.as_bytes(),
&self.salt,
self.password_iterations as u32);
self.reset_security_stamp();
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
}
pub fn reset_security_stamp(&mut self) {
self.security_stamp = Uuid::new_v4().to_string();
self.security_stamp = crate::util::get_uuid();
}
}
use super::{Cipher, Device, Folder, TwoFactor, UserOrgType, UserOrganization};
use crate::db::schema::{invitations, users};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use db::DbConn;
use db::schema::{users, invitations};
use crate::api::EmptyResult;
use crate::error::MapResult;
/// Database methods
impl User {
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
use super::UserOrganization;
use super::TwoFactor;
pub fn to_json(&self, conn: &DbConn) -> Value {
let orgs = UserOrganization::find_by_user(&self.uuid, conn);
let orgs_json: Vec<JsonValue> = orgs.iter().map(|c| c.to_json(&conn)).collect();
let orgs_json: Vec<Value> = orgs.iter().map(|c| c.to_json(&conn)).collect();
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty();
let twofactor_enabled = TwoFactor::find_by_user(&self.uuid, conn).len() > 0;
// TODO: Might want to save the status field in the DB
let status = if self.password_hash.is_empty() {
UserStatus::Invited
} else {
UserStatus::Enabled
};
json!({
"_Status": status as i32,
"Id": self.uuid,
"Name": self.name,
"Email": self.email,
@@ -132,57 +148,93 @@ impl User {
})
}
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("User email can't be empty")
}
pub fn save(&mut self, conn: &DbConn) -> bool {
self.updated_at = Utc::now().naive_utc();
match diesel::replace_into(users::table) // Insert or update
diesel::replace_into(users::table) // Insert or update
.values(&*self)
.execute(&**conn) {
Ok(1) => true, // One row inserted
_ => false,
}
.execute(&**conn)
.map_res("Error saving user")
}
pub fn delete(self, conn: &DbConn) -> bool {
match diesel::delete(users::table.filter(
users::uuid.eq(self.uuid)))
.execute(&**conn) {
Ok(1) => true, // One row deleted
_ => false,
pub fn delete(self, conn: &DbConn) -> EmptyResult {
for user_org in UserOrganization::find_by_user(&self.uuid, &*conn) {
if user_org.type_ == UserOrgType::Owner {
let owner_type = UserOrgType::Owner as i32;
if UserOrganization::find_by_org_and_type(&user_org.org_uuid, owner_type, &conn).len() <= 1 {
err!("Can't delete last owner")
}
}
}
UserOrganization::delete_all_by_user(&self.uuid, &*conn)?;
Cipher::delete_all_by_user(&self.uuid, &*conn)?;
Folder::delete_all_by_user(&self.uuid, &*conn)?;
Device::delete_all_by_user(&self.uuid, &*conn)?;
TwoFactor::delete_all_by_user(&self.uuid, &*conn)?;
Invitation::take(&self.email, &*conn); // Delete invitation if any
diesel::delete(users::table.filter(users::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error deleting 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(){
println!("Warning: 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_revision(&mut self, conn: &DbConn) -> QueryResult<()> {
self.updated_at = Utc::now().naive_utc();
diesel::update(
users::table.filter(
users::uuid.eq(&self.uuid)
)
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,
)
.set(users::updated_at.eq(&self.updated_at))
.execute(&**conn).and(Ok(()))
.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();
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")
}
pub fn find_by_mail(mail: &str, conn: &DbConn) -> Option<Self> {
let lower_mail = mail.to_lowercase();
users::table
.filter(users::email.eq(lower_mail))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
users::table
.filter(users::uuid.eq(uuid))
.first::<Self>(&**conn).ok()
users::table.filter(users::uuid.eq(uuid)).first::<Self>(&**conn).ok()
}
pub fn get_all(conn: &DbConn) -> Vec<Self> {
users::table.load::<Self>(&**conn).expect("Error loading users")
}
}
@@ -195,37 +247,39 @@ pub struct Invitation {
impl Invitation {
pub fn new(email: String) -> Self {
Self {
email
Self { email }
}
pub fn save(&self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("Invitation email can't be empty")
}
}
pub fn save(&mut self, conn: &DbConn) -> QueryResult<()> {
diesel::replace_into(invitations::table)
.values(&*self)
.execute(&**conn)
.and(Ok(()))
.values(self)
.execute(&**conn)
.map_res("Error saving invitation")
}
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
diesel::delete(invitations::table.filter(
invitations::email.eq(self.email)))
.execute(&**conn)
.and(Ok(()))
pub fn delete(self, conn: &DbConn) -> EmptyResult {
diesel::delete(invitations::table.filter(invitations::email.eq(self.email)))
.execute(&**conn)
.map_res("Error deleting invitation")
}
pub fn find_by_mail(mail: &str, conn: &DbConn) -> Option<Self> {
let lower_mail = mail.to_lowercase();
invitations::table
.filter(invitations::email.eq(lower_mail))
.first::<Self>(&**conn).ok()
.first::<Self>(&**conn)
.ok()
}
pub fn take(mail: &str, conn: &DbConn) -> bool {
CONFIG.invitations_allowed &&
match Self::find_by_mail(mail, &conn) {
Some(invitation) => invitation.delete(&conn).is_ok(),
None => false
}
CONFIG.invitations_allowed()
&& match Self::find_by_mail(mail, &conn) {
Some(invitation) => invitation.delete(&conn).is_ok(),
None => false,
}
}
}

View File

@@ -4,6 +4,7 @@ table! {
cipher_uuid -> Text,
file_name -> Text,
file_size -> Integer,
key -> Nullable<Text>,
}
}
@@ -72,6 +73,12 @@ table! {
}
}
table! {
invitations (email) {
email -> Text,
}
}
table! {
organizations (uuid) {
uuid -> Text,
@@ -110,12 +117,8 @@ table! {
security_stamp -> Text,
equivalent_domains -> Text,
excluded_globals -> Text,
}
}
table! {
invitations (email) {
email -> Text,
client_kdf_type -> Integer,
client_kdf_iter -> Integer,
}
}
@@ -164,6 +167,7 @@ allow_tables_to_appear_in_same_query!(
devices,
folders,
folders_ciphers,
invitations,
organizations,
twofactor,
users,

216
src/error.rs Normal file
View File

@@ -0,0 +1,216 @@
//
// Error generator macro
//
use std::error::Error as StdError;
macro_rules! make_error {
( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => {
const BAD_REQUEST: u16 = 400;
#[derive(Display)]
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_code: BAD_REQUEST }
}
})+
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match &self.error {$( ErrorKind::$name(e) => $src_fn(e), )+}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match &self.error {$(
ErrorKind::$name(e) => f.write_str(&$usr_msg_fun(e, &self.message)),
)+}
}
}
};
}
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::option::NoneError as NoneErr;
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.
//
// 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(DieselErr): _has_source, _api_error,
U2fError(U2fErr): _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,
}
// This is implemented by hand because NoneError doesn't implement neither Display nor Error
impl From<NoneErr> for Error {
fn from(_: NoneErr) -> Self {
Error::from(("NoneError", String::new()))
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self.source() {
Some(e) => write!(f, "{}.\n[CAUSE] {:#?}", self.message, e),
None => write!(f, "{}. {}", self.message, self.error),
}
}
}
impl Error {
pub fn new<M: Into<String>, N: Into<String>>(usr_msg: M, log_msg: N) -> Self {
(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> {
fn map_res(self, msg: &str) -> Result<S, Error>;
}
impl<S, E: Into<Error>> MapResult<S> for Result<S, E> {
fn map_res(self, msg: &str) -> Result<S, Error> {
self.map_err(|e| e.into().with_msg(msg))
}
}
impl<E: Into<Error>> MapResult<()> for Result<usize, E> {
fn map_res(self, msg: &str) -> Result<(), Error> {
self.and(Ok(())).map_res(msg)
}
}
impl<S> MapResult<S> for Option<S> {
fn map_res(self, msg: &str) -> Result<S, Error> {
self.ok_or_else(|| Error::new(msg, ""))
}
}
fn _has_source<T>(e: T) -> Option<T> {
Some(e)
}
fn _no_source<T, S>(_: T) -> Option<S> {
None
}
fn _serialize(e: &impl serde::Serialize, _msg: &str) -> String {
serde_json::to_string(e).unwrap()
}
fn _api_error(_: &impl std::any::Any, msg: &str) -> String {
let json = json!({
"Message": "",
"error": "",
"error_description": "",
"ValidationErrors": {"": [ msg ]},
"ErrorModel": {
"Message": msg,
"Object": "error"
},
"Object": "error"
});
_serialize(&json, "")
}
//
// Rocket responder impl
//
use std::io::Cursor;
use rocket::http::{ContentType, Status};
use rocket::request::Request;
use rocket::response::{self, Responder, Response};
impl<'r> Responder<'r> for Error {
fn respond_to(self, _: &Request) -> response::Result<'r> {
let usr_msg = format!("{}", self);
error!("{:#?}", self);
let code = Status::from_code(self.error_code).unwrap_or(Status::BadRequest);
Response::build()
.status(code)
.header(ContentType::JSON)
.sized_body(Cursor::new(usr_msg))
.ok()
}
}
//
// Error return macros
//
#[macro_export]
macro_rules! err {
($msg:expr) => {{
return Err(crate::error::Error::new($msg, $msg));
}};
($usr_msg:expr, $log_value:expr) => {{
return Err(crate::error::Error::new($usr_msg, $log_value));
}};
}
#[macro_export]
macro_rules! err_json {
($expr:expr) => {{
return Err(crate::error::Error::from($expr));
}};
}
#[macro_export]
macro_rules! err_handler {
($expr:expr) => {{
error!("Unauthorized Error: {}", $expr);
return rocket::Outcome::Failure((rocket::http::Status::Unauthorized, $expr));
}};
($usr_msg:expr, $log_value:expr) => {{
error!("Unauthorized Error: {}. {}", $usr_msg, $log_value);
return rocket::Outcome::Failure((rocket::http::Status::Unauthorized, $usr_msg));
}};
}

View File

@@ -1,63 +1,174 @@
use std::error::Error;
use native_tls::{Protocol, TlsConnector};
use lettre::{EmailTransport, SmtpTransport, ClientTlsParameters, ClientSecurity};
use lettre::smtp::{ConnectionReuseParameters, SmtpTransportBuilder};
use lettre::smtp::authentication::Credentials;
use lettre_email::EmailBuilder;
use lettre::smtp::ConnectionReuseParameters;
use lettre::{ClientSecurity, ClientTlsParameters, SmtpClient, SmtpTransport, Transport};
use lettre_email::{EmailBuilder, MimeMultipartType, PartBuilder};
use native_tls::{Protocol, TlsConnector};
use quoted_printable::encode_to_str;
use MailConfig;
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 {
let mut tls_builder = TlsConnector::builder().unwrap();
tls_builder.supported_protocols(&[Protocol::Tlsv11, Protocol::Tlsv12]).unwrap();
ClientSecurity::Required(
ClientTlsParameters::new(config.smtp_host.to_owned(), tls_builder.build().unwrap())
)
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();
let params = ClientTlsParameters::new(host.clone(), tls);
if CONFIG.smtp_explicit_tls() {
ClientSecurity::Wrapper(params)
} else {
ClientSecurity::Required(params)
}
} else {
ClientSecurity::None
};
let smtp_transport = SmtpTransportBuilder::new(
(config.smtp_host.to_owned().as_str(), config.smtp_port),
client_security
).unwrap();
let smtp_client = SmtpClient::new((host.as_str(), CONFIG.smtp_port()), client_security).unwrap();
let smtp_transport = match (&config.smtp_username, &config.smtp_password) {
(Some(username), Some(password)) => {
smtp_transport.credentials(Credentials::new(username.to_owned(), password.to_owned()))
},
(_, _) => smtp_transport,
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,
};
smtp_transport
smtp_client
.smtp_utf8(true)
.connection_reuse(ConnectionReuseParameters::NoReuse)
.build()
.transport()
}
pub fn send_password_hint(address: &str, hint: Option<String>, config: &MailConfig) -> Result<(), String> {
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".to_string())
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"),
};
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(
address: &str,
uuid: &str,
org_id: Option<String>,
org_user_id: Option<String>,
org_name: &str,
invited_by_email: Option<String>,
) -> EmptyResult {
let claims = generate_invite_claims(
uuid.to_string(),
String::from(address),
org_id.clone(),
org_user_id.clone(),
invited_by_email.clone(),
);
let invite_token = encode_jwt(&claims);
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) -> 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_html, &body_text)
}
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_html, &body_text)
}
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
let html = PartBuilder::new()
.body(encode_to_str(body_html))
.header(("Content-Type", "text/html; charset=utf-8"))
.header(("Content-Transfer-Encoding", "quoted-printable"))
.build();
let text = PartBuilder::new()
.body(encode_to_str(body_text))
.header(("Content-Type", "text/plain; charset=utf-8"))
.header(("Content-Transfer-Encoding", "quoted-printable"))
.build();
let alternative = PartBuilder::new()
.message_type(MimeMultipartType::Alternative)
.child(text)
.child(html);
let email = EmailBuilder::new()
.to(address)
.from((config.smtp_from.to_owned(), "Bitwarden-rs"))
.from((CONFIG.smtp_from().as_str(), CONFIG.smtp_from_name().as_str()))
.subject(subject)
.body(body)
.build().unwrap();
.child(alternative.build())
.build()
.map_err(|e| Error::new("Error building email", e.to_string()))?;
match mailer(config).send(&email) {
Ok(_) => Ok(()),
Err(e) => Err(e.description().to_string()),
}
let mut transport = mailer();
let result = transport
.send(email.into())
.map_err(|e| Error::new("Error sending email", e.to_string()))
.and(Ok(()));
// Explicitly close the connection, in case of error
transport.close();
result
}

View File

@@ -1,57 +1,205 @@
#![feature(plugin, custom_derive)]
#![plugin(rocket_codegen)]
#![allow(proc_macro_derive_resolution_fallback)] // TODO: Remove this when diesel update fixes warnings
#![feature(proc_macro_hygiene, decl_macro, vec_remove_item, try_trait)]
#![recursion_limit = "256"]
#[macro_use]
extern crate rocket;
extern crate rocket_contrib;
extern crate reqwest;
extern crate multipart;
extern crate serde;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate log;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
extern crate ring;
extern crate uuid;
extern crate chrono;
extern crate oath;
extern crate data_encoding;
extern crate jsonwebtoken as jwt;
extern crate u2f;
extern crate dotenv;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate derive_more;
#[macro_use]
extern crate num_derive;
extern crate num_traits;
extern crate lettre;
extern crate lettre_email;
extern crate native_tls;
extern crate fast_chemail;
use std::{env, path::Path, process::{exit, Command}};
use rocket::Rocket;
use std::{
path::Path,
process::{exit, Command},
};
#[macro_use]
mod error;
mod api;
mod auth;
mod config;
mod crypto;
mod db;
mod mail;
mod util;
mod api;
mod db;
mod crypto;
mod auth;
mod mail;
pub use config::CONFIG;
pub use error::{Error, MapResult};
fn init_rocket() -> Rocket {
rocket::ignite()
.mount("/", api::web_routes())
.mount("/api", api::core_routes())
.mount("/identity", api::identity_routes())
.mount("/icons", api::icons_routes())
.mount("/notifications", api::notifications_routes())
.manage(db::init_pool())
fn main() {
launch_info();
if CONFIG.extended_logging() {
init_logging().ok();
}
check_db();
check_rsa_keys();
check_web_vault();
migrations::run_migrations();
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> {
use std::str::FromStr;
let mut logger = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"{}[{}][{}] {}",
chrono::Local::now().format("[%Y-%m-%d %H:%M:%S]"),
record.target(),
record.level(),
message
))
})
.level(log::LevelFilter::from_str(&CONFIG.log_level()).expect("Valid log level"))
// Hide unknown certificate errors if using self-signed
.level_for("rustls::session", log::LevelFilter::Off)
// Hide failed to close stream messages
.level_for("hyper::server", log::LevelFilter::Warn)
.chain(std::io::stdout());
if let Some(log_file) = CONFIG.log_file() {
logger = logger.chain(fern::log_file(log_file)?);
}
#[cfg(not(windows))]
{
if cfg!(feature = "enable_syslog") || CONFIG.use_syslog() {
logger = chain_syslog(logger);
}
}
logger.apply()?;
Ok(())
}
#[cfg(not(windows))]
fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch {
let syslog_fmt = syslog::Formatter3164 {
facility: syslog::Facility::LOG_USER,
hostname: None,
process: "bitwarden_rs".into(),
pid: 0,
};
match syslog::unix(syslog_fmt) {
Ok(sl) => logger.chain(sl),
Err(e) => {
error!("Unable to connect to syslog: {:?}", e);
logger
}
}
}
fn check_db() {
let url = CONFIG.database_url();
let path = Path::new(&url);
if let Some(parent) = path.parent() {
use std::fs;
if fs::create_dir_all(parent).is_err() {
error!("Error creating database directory");
exit(1);
}
}
// 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()) {
info!("JWT keys don't exist, checking if OpenSSL is available...");
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")
.args(&["genrsa", "-out", &pem])
.status()
.expect("Failed to create private pem file")
.success();
success &= Command::new("openssl")
.args(&["rsa", "-in", &pem, "-outform", "DER", "-out", &priv_der])
.status()
.expect("Failed to create private der file")
.success();
success &= Command::new("openssl")
.args(&["rsa", "-in", &priv_der, "-inform", "DER"])
.args(&["-RSAPublicKey_out", "-outform", "DER", "-out", &pub_der])
.status()
.expect("Failed to create public der file")
.success();
if success {
info!("Keys created correctly.");
} else {
error!("Error creating keys, exiting...");
exit(1);
}
}
}
fn check_web_vault() {
if !CONFIG.web_vault_enabled() {
return;
}
let index_path = Path::new(&CONFIG.web_vault_folder()).join("index.html");
if !index_path.exists() {
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);
}
}
// Embed the migrations from the migrations folder into the application
@@ -63,210 +211,41 @@ mod 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 = ::db::get_connection().expect("Can't conect to DB");
let connection = crate::db::get_connection().expect("Can't connect to DB");
use std::io::stdout;
embedded_migrations::run_with_output(&connection, &mut stdout()).expect("Can't run migrations");
}
}
fn main() {
check_db();
check_rsa_keys();
check_web_vault();
migrations::run_migrations();
fn launch_rocket() {
// Create Rocket object, this stores current log level and sets it's own
let rocket = rocket::ignite();
init_rocket().launch();
}
fn check_db() {
let path = Path::new(&CONFIG.database_url);
if let Some(parent) = path.parent() {
use std::fs;
if fs::create_dir_all(parent).is_err() {
println!("Error creating database directory");
exit(1);
}
// If we aren't logging the mounts, we force the logging level down
if !CONFIG.log_mounts() {
log::set_max_level(log::LevelFilter::Warn);
}
// Turn on WAL in SQLite
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");
}
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());
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) {
println!("JWT keys don't exist, checking if OpenSSL is available...");
Command::new("openssl")
.arg("version")
.output().unwrap_or_else(|_| {
println!("Can't create keys because OpenSSL is not available, make sure it's installed and available on the PATH");
exit(1);
});
println!("OpenSSL detected, creating keys...");
let mut success = Command::new("openssl").arg("genrsa")
.arg("-out").arg(&CONFIG.private_rsa_key_pem)
.output().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().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().expect("Failed to create public der file")
.status.success();
if success {
println!("Keys created correctly.");
} else {
println!("Error creating keys, exiting...");
exit(1);
}
}
}
fn check_web_vault() {
if !CONFIG.web_vault_enabled {
return;
// Force the level up for the fairings, managed state and lauch
if !CONFIG.log_mounts() {
log::set_max_level(log::LevelFilter::max());
}
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html");
let rocket = rocket
.manage(db::init_pool())
.manage(api::start_notification_server())
.attach(util::AppHeaders());
if !index_path.exists() {
println!("Web vault is not found. Please follow the steps in the README to install it");
exit(1);
}
}
lazy_static! {
// Load the config from .env or from environment variables
static ref CONFIG: Config = Config::load();
}
#[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>,
}
impl MailConfig {
fn load() -> Option<Self> {
let smtp_host = env::var("SMTP_HOST").ok();
// When SMTP_HOST is absent, we assume the user does not want to enable it.
if smtp_host.is_none() {
return None
}
let smtp_ssl = util::parse_option_string(env::var("SMTP_SSL").ok()).unwrap_or(true);
let smtp_port = util::parse_option_string(env::var("SMTP_PORT").ok())
.unwrap_or_else(|| {
if smtp_ssl {
587u16
} else {
25u16
}
});
let smtp_username = env::var("SMTP_USERNAME").ok();
let smtp_password = env::var("SMTP_PASSWORD").ok().or_else(|| {
if smtp_username.as_ref().is_some() {
println!("Please specify SMTP_PASSWORD to enable SMTP support.");
exit(1);
} else {
None
}
});
Some(MailConfig {
smtp_host: smtp_host.unwrap(),
smtp_port: smtp_port,
smtp_ssl: smtp_ssl,
smtp_from: util::parse_option_string(env::var("SMTP_FROM").ok())
.unwrap_or("bitwarden-rs@localhost".to_string()),
smtp_username: smtp_username,
smtp_password: smtp_password,
})
}
}
#[derive(Debug)]
pub struct Config {
database_url: String,
icon_cache_folder: String,
attachments_folder: String,
private_rsa_key: String,
private_rsa_key_pem: String,
public_rsa_key: String,
web_vault_folder: String,
web_vault_enabled: bool,
local_icon_extractor: bool,
signups_allowed: bool,
invitations_allowed: bool,
password_iterations: i32,
show_password_hint: bool,
domain: String,
domain_set: bool,
mail: Option<MailConfig>,
}
impl Config {
fn load() -> Self {
dotenv::dotenv().ok();
let df = env::var("DATA_FOLDER").unwrap_or("data".into());
let key = env::var("RSA_KEY_FILENAME").unwrap_or(format!("{}/{}", &df, "rsa_key"));
let domain = env::var("DOMAIN");
Config {
database_url: env::var("DATABASE_URL").unwrap_or(format!("{}/{}", &df, "db.sqlite3")),
icon_cache_folder: env::var("ICON_CACHE_FOLDER").unwrap_or(format!("{}/{}", &df, "icon_cache")),
attachments_folder: env::var("ATTACHMENTS_FOLDER").unwrap_or(format!("{}/{}", &df, "attachments")),
private_rsa_key: format!("{}.der", &key),
private_rsa_key_pem: format!("{}.pem", &key),
public_rsa_key: format!("{}.pub.der", &key),
web_vault_folder: env::var("WEB_VAULT_FOLDER").unwrap_or("web-vault/".into()),
web_vault_enabled: util::parse_option_string(env::var("WEB_VAULT_ENABLED").ok()).unwrap_or(true),
local_icon_extractor: util::parse_option_string(env::var("LOCAL_ICON_EXTRACTOR").ok()).unwrap_or(false),
signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(true),
invitations_allowed: util::parse_option_string(env::var("INVITATIONS_ALLOWED").ok()).unwrap_or(true),
password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000),
show_password_hint: util::parse_option_string(env::var("SHOW_PASSWORD_HINT").ok()).unwrap_or(true),
domain_set: domain.is_ok(),
domain: domain.unwrap_or("http://localhost".into()),
mail: MailConfig::load(),
}
}
// Launch and print error if there is one
// The launch will restore the original logging level
error!("Launch error {:#?}", rocket.launch());
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -260,7 +260,8 @@
"Type": 26,
"Domains": [
"steampowered.com",
"steamcommunity.com"
"steamcommunity.com",
"steamgames.com"
],
"Excluded": false
},

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}}
{{#case _Status 1}}
<span class="badge badge-warning ml-2">Invited</span>
{{/case}}
<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,65 +1,93 @@
///
/// Macros
///
#[macro_export]
macro_rules! err {
($err:expr, $msg:expr) => {{
println!("ERROR: {}", $msg);
err_json!(json!({
"Message": $err,
"ValidationErrors": {
"": [$msg,],
},
"ExceptionMessage": null,
"ExceptionStackTrace": null,
"InnerExceptionMessage": null,
"Object": "error",
}))
}};
($msg:expr) => { err!("The model state is invalid", $msg) }
//
// Web Headers and caching
//
use rocket::fairing::{Fairing, Info, Kind};
use rocket::response::{self, Responder};
use rocket::{Request, Response};
pub struct AppHeaders();
impl Fairing for AppHeaders {
fn info(&self) -> Info {
Info {
name: "Application Headers",
kind: Kind::Response,
}
}
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");
res.set_raw_header("X-XSS-Protection", "1; mode=block");
let csp = "frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb moz-extension://*;";
res.set_raw_header("Content-Security-Policy", csp);
// Disable cache unless otherwise specified
if !res.headers().contains("cache-control") {
res.set_raw_header("Cache-Control", "no-cache, no-store, max-age=0");
}
}
}
#[macro_export]
macro_rules! err_json {
($expr:expr) => {{
return Err($crate::rocket::response::status::BadRequest(Some($crate::rocket_contrib::Json($expr))));
}}
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")
}
}
#[macro_export]
macro_rules! err_handler {
($expr:expr) => {{
println!("ERROR: {}", $expr);
return $crate::rocket::Outcome::Failure(($crate::rocket::http::Status::Unauthorized, $expr));
}}
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
///
use std::path::Path;
use std::io::Read;
//
// File handling
//
use std::fs::{self, File};
use std::io::{Read, Result as IOResult};
use std::path::Path;
pub fn file_exists(path: &str) -> bool {
Path::new(path).exists()
}
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
let mut file = File::open(Path::new(path))
.map_err(|e| format!("Error opening file: {}", e))?;
pub fn read_file(path: &str) -> IOResult<Vec<u8>> {
let mut contents: Vec<u8> = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Error reading file: {}", e))?;
let mut file = File::open(Path::new(path))?;
file.read_to_end(&mut contents)?;
Ok(contents)
}
pub fn delete_file(path: &str) -> bool {
let res = fs::remove_file(path).is_ok();
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);
if let Some(parent) = Path::new(path).parent() {
// If the directory isn't empty, this returns an error, which we ignore
@@ -70,11 +98,10 @@ pub fn delete_file(path: &str) -> bool {
res
}
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 {
@@ -84,18 +111,22 @@ pub fn get_display_size(size: i32) -> String {
} else {
break;
}
};
}
// Round to two decimals
size = (size * 100.).round() / 100.;
format!("{} {}", size, UNITS[unit_counter])
}
pub fn get_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
///
/// String util methods
///
//
// String util methods
//
use std::ops::Try;
use std::str::FromStr;
pub fn upcase_first(s: &str) -> String {
@@ -106,17 +137,34 @@ pub fn upcase_first(s: &str) -> String {
}
}
pub fn parse_option_string<S, T>(string: Option<S>) -> Option<T> where S: AsRef<str>, T: FromStr {
if let Some(Ok(value)) = string.map(|s| s.as_ref().parse::<T>()) {
pub fn try_parse_string<S, T, U>(string: impl Try<Ok = S, Error = U>) -> Option<T>
where
S: AsRef<str>,
T: FromStr,
{
if let Ok(Ok(value)) = string.into_result().map(|s| s.as_ref().parse::<T>()) {
Some(value)
} else {
None
}
}
///
/// Date util methods
///
//
// Env methods
//
use std::env;
pub fn get_env<V>(key: &str) -> Option<V>
where
V: FromStr,
{
try_parse_string(env::var(key))
}
//
// Date util methods
//
use chrono::NaiveDateTime;
@@ -126,9 +174,9 @@ pub fn format_date(date: &NaiveDateTime) -> String {
date.format(DATETIME_FORMAT).to_string()
}
///
/// Deserialization methods
///
//
// Deserialization methods
//
use std::fmt;
@@ -144,10 +192,11 @@ pub struct UpCase<T: DeserializeOwned> {
pub data: T,
}
/// https://github.com/serde-rs/serde/issues/586
// https://github.com/serde-rs/serde/issues/586
pub fn upcase_deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where T: DeserializeOwned,
D: Deserializer<'de>
where
T: DeserializeOwned,
D: Deserializer<'de>,
{
let d = deserializer.deserialize_any(UpCaseVisitor)?;
T::deserialize(d).map_err(de::Error::custom)
@@ -163,56 +212,84 @@ impl<'de> Visitor<'de> for UpCaseVisitor {
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where A: MapAccess<'de>
where
A: MapAccess<'de>,
{
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))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where A: SeqAccess<'de> {
where
A: SeqAccess<'de>,
{
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
}
}
fn _process_key(key: &str) -> String {
match key.to_lowercase().as_ref() {
"ssn" => "SSN".into(),
_ => self::upcase_first(key)
_ => self::upcase_first(key),
}
}
//
// Retry methods
//
pub fn retry<F, T, E>(func: F, max_tries: i32) -> Result<T, E>
where
F: Fn() -> Result<T, E>,
{
use std::{thread::sleep, time::Duration};
let mut tries = 0;
loop {
match func() {
ok @ Ok(_) => return ok,
err @ Err(_) => {
tries += 1;
if tries >= max_tries {
return err;
}
sleep(Duration::from_millis(500));
}
}
}
}