Compare commits

..

140 Commits
1.14 ... 1.16.0

Author SHA1 Message Date
Daniel García
11845d9f5b Merge pull request #1061 from jjlin/use-strip-prefix
Use `strip_prefix()` instead of `trim_start_matches()` as appropriate
2020-07-21 16:31:31 +02:00
Jeremy Lin
de70fbf88a Use strip_prefix() instead of trim_start_matches() as appropriate
As of Rust 1.45.0, `strip_prefix()` is now stable.
2020-07-20 22:33:13 -07:00
Daniel García
0b04caab78 Merge pull request #1029 from jjlin/multi-arch
Multi-arch image support
2020-07-16 22:59:12 +02:00
Jeremy Lin
4c78c5a9c9 Tag latest releases as latest and alpine 2020-07-15 20:03:34 -07:00
Jeremy Lin
73f0841f17 Clean up arch-specific tags if Docker Hub credentials are provided 2020-07-15 20:03:34 -07:00
Jeremy Lin
4559e85daa Multi-arch image support 2020-07-15 20:03:34 -07:00
Jeremy Lin
bbef332e25 Dockerfile.j2: remove dead code 2020-07-15 20:03:34 -07:00
Daniel García
1e950c7dbc Replace IP support in preparation for compiling on stable, included some tests to check that the code matches the unstable implementation 2020-07-15 00:00:03 +02:00
Daniel García
f14e19a3d8 Don't compile the regexes each time 2020-07-14 21:58:27 +02:00
Daniel García
668d5c23dc Removed try_trait and some formatting, particularly around imports 2020-07-14 18:34:22 +02:00
Daniel García
fb6f96f5c3 Updated dependencies 2020-07-14 16:08:11 +02:00
Daniel García
6e6e34ff18 Merge pull request #1055 from jjlin/pg
Fix error in PostgreSQL build
2020-07-11 11:17:45 +02:00
Jeremy Lin
790146bfac Fix error in PostgreSQL build 2020-07-10 17:23:02 -07:00
Daniel García
af625930d6 Merge pull request #1049 from jjlin/local-tz
Use local time in email notifications for new device logins
2020-07-08 19:39:17 +02:00
Jeremy Lin
a28ebcb401 Use local time in email notifications for new device logins
In this implementation, the `TZ` environment variable must be set
in order for the formatted output to use a more user-friendly
time zone abbreviation (e.g., `UTC`). Otherwise, the output uses
the time zone's UTC offset (e.g., `+00:00`).
2020-07-07 21:30:18 -07:00
Daniel García
77e47ddd1f Merge pull request #1042 from jjlin/hide-passwords
Add support for hiding passwords in a collection
2020-07-06 18:56:06 +02:00
Daniel García
5b620ba6cd Merge pull request #1048 from jjlin/init
Add startup script to support init operations
2020-07-06 18:15:18 +02:00
Jeremy Lin
d5f9b33f66 Add startup script to support init operations
This is useful for making local customizations upon container start. To use
this feature, mount a script into the container as `/etc/bitwarden_rs.sh`
and/or a directory of scripts as `/etc/bitwarden_rs.d`. In the latter case,
only files with an `.sh` extension are sourced, so files with other
extensions (e.g., data/config files) can reside in the same dir.

Note that the init scripts are run each time the container starts (not just
the first time), so these scripts should be idempotent.
2020-07-05 15:26:20 -07:00
Daniel García
596c9b8691 Add option to set name during HELO in email settings 2020-07-05 01:59:15 +02:00
Daniel García
d4357eb55a Updated dependencies ans web vault version 2020-07-05 01:38:16 +02:00
Daniel García
b37f0dfde3 Merge pull request #1044 from ArmaanT/master
Allow postgres:// in DATABASE_URL
2020-07-05 01:07:29 +02:00
Armaan Tobaccowalla
624791e09a Allow postgres:// DATABASE_URL 2020-07-04 16:13:27 -04:00
Jeremy Lin
f9a73a9bbe More cipher optimization/cleanup 2020-07-03 10:49:10 -07:00
Jeremy Lin
35868dd72c Optimize cipher queries 2020-07-03 09:00:33 -07:00
Jeremy Lin
979d010dc2 Add support for hiding passwords in a collection
Ref: https://github.com/bitwarden/server/pull/743
2020-07-02 21:51:20 -07:00
Daniel García
b34d548246 Update dependencies 2020-06-22 17:15:20 +02:00
Daniel García
a87646b8cb Some format changes to main.rs 2020-06-15 23:40:39 +02:00
Daniel García
a2411eef56 Updated dependencies 2020-06-15 23:04:52 +02:00
Daniel García
52ed8e4d75 Merge pull request #1026 from BlackDex/issue-1022
Fixes #1022 cloning with attachments
2020-06-07 19:53:47 +02:00
BlackDex
24c914799d Fixes #1022 cloning with attachments
When a cipher has one or more attachments it wasn't able to be cloned.
This commit fixes that issue.
2020-06-07 17:57:04 +02:00
Daniel García
db53511855 Merge pull request #1020 from BlackDex/admin-interface
Fixed wrong status if there is an update.
2020-06-04 18:50:00 +02:00
BlackDex
325691e588 Fixed wrong status if there is an update.
- Checking the sha hash first if this is also in the server version.
- Added a badge to show if you are on a branched build.
2020-06-04 17:05:17 +02:00
Daniel García
fac3cb687d Merge pull request #1019 from xoxys/master
Add back openssl crate
2020-06-04 01:24:28 +02:00
Robert Kaussow
afbf1db331 add back openssl crate 2020-06-04 01:21:30 +02:00
Daniel García
1aefaec297 Merge pull request #1018 from BlackDex/admin-interface
Admin interface
2020-06-03 22:48:03 +02:00
Daniel García
f1d3fb5d40 Merge pull request #1017 from dprobinson/patch-1
Added missing ENV Variable for Implicit TLS
2020-06-03 22:47:53 +02:00
BlackDex
ac2723f898 Updated Organizations overview
- Changed HTML to match users overview
- Added User count
- Added Org cipher amount
- Added Attachment count and size
2020-06-03 20:37:31 +02:00
BlackDex
2fffaec226 Added attachment info per user and some layout fix
- Added the amount and size of the attachments per user
- Changed the items count function a bit
- Some small layout changes
2020-06-03 17:57:03 +02:00
BlackDex
5c54dfee3a Fixed an issue when DNS resolving fails.
In the event of a failed DNS Resolving checking for new versions will
cause a huge delay, and in the end a timeout when loading the page.

- Check if DNS resolving failed, if that is the case, do not check for
  new versions
- Changed `fn get_github_api` to make use of structs
- Added a timeout of 10 seconds for the version check requests
- Moved the "Unknown" lables to the "Latest" lable
2020-06-03 17:07:32 +02:00
David P Robinson
967d2d78ec Added missing ENV Variable for Implicit TLS 2020-06-02 23:46:26 +01:00
Daniel García
1aa5e0d4dc Merge pull request #1012 from BlackDex/admin-interface
Updated js/css libraries and fixed smallscreen err
2020-06-01 20:07:13 +02:00
BlackDex
b47cf97409 Updated js/css libraries and fixed smallscreen err
- Updated bootstrap js and css to the latest version
- Fixed issue with small-screens where the menu overlaps the token input
  - The menu now collapses to a hamburger menu
  - Menu's only accessable when logedin are hidden when you are not
- Changed Users Overview to use a table to prevent small-screen issues.
2020-06-01 18:58:38 +02:00
Daniel García
5e802f8aa3 Update lettre to alpha release instead of git commit, and update the rest of dependencies while we are at it 2020-05-31 17:58:06 +02:00
Daniel García
0bdeb02a31 Merge pull request #1009 from jjlin/email-subject
Don't HTML-escape email subject lines
2020-05-31 00:22:58 +02:00
Daniel García
b03698fadb Merge pull request #1010 from jjlin/admin-url
Avoid double-slashes in the admin URL
2020-05-31 00:22:46 +02:00
Jeremy Lin
39d1a09704 Avoid double-slashes in the admin URL 2020-05-30 01:06:40 -07:00
Jeremy Lin
a447e4e7ef Don't HTML-escape email subject lines
For example, this causes org names like `X&Y` to appear as `X&Y`.
2020-05-30 00:36:43 -07:00
Daniel García
4eee6e7aee Merge pull request #1007 from BlackDex/admin-interface
Admin interface restyle
2020-05-28 20:54:11 +02:00
BlackDex
b6fde857a7 Added version check to diagnostics
- Added a version check based upon the github api information.
2020-05-28 20:25:25 +02:00
BlackDex
3c66deb5cc Redesign of the admin interface.
Main changes:
 - Splitted up settings and users into two separate pages.
 - Added verified shield when the e-mail address has been verified.
 - Added the amount of personal items in the database to the users overview.
 - Added Organizations and Diagnostics pages.
   - Shows if DNS resolving works.
   - Shows if there is a posible time drift.
   - Shows current versions of server and web-vault.
 - Optimized logo-gray.png using optipng

Items which can be added later:
 - Amount of cipher items accessible for a user, not only his personal items.
 - Amount of users per Org
 - Version update check in the diagnostics overview.
 - Copy/Pasteable runtime config which has sensitive data changed or removed for support questions either on the forum or github issues.
 - Option to delete Orgs and all its passwords (when there are no members anymore).
 - Etc....
2020-05-28 10:46:25 +02:00
Daniel García
4146612a32 Merge pull request #1006 from jjlin/email-change
Allow email changes for existing accounts even when signups are disabled
2020-05-27 18:18:21 +02:00
Jeremy Lin
a314933557 Allow email changes for existing accounts even when signups are disabled 2020-05-24 14:38:19 -07:00
Daniel García
c5d7e3f2bc Merge pull request #1003 from frdescam/fix_arm_displaysize
Use format! for rounding to fix arm issue
2020-05-23 13:10:06 +02:00
Daniel García
c95a2881b5 Merge pull request #998 from frdescam/fix_email_templates
Fixing bad width in 2FA email template
2020-05-23 13:09:44 +02:00
fdeĉ
4c3727b4a3 use format! for rounding to fix arm issue 2020-05-22 12:10:56 +02:00
Daniel García
a1f304dff7 Update web vault to v2.14.0 2020-05-21 22:49:15 +02:00
Daniel García
a8870eef0d Convert to f32 before rounding to fix arm issue 2020-05-20 17:58:39 +02:00
François
afaebc6cf3 fixing hard coded width email templates 2020-05-20 13:38:04 +02:00
François
8f4a1f4fc2 fixing bad width in 2FA email template 2020-05-18 12:27:21 +02:00
Daniel García
0807783388 Add ip on totp miss 2020-05-14 00:19:50 +02:00
Daniel García
80d4061d14 Update dependencies 2020-05-14 00:18:18 +02:00
Daniel García
dc2f8e5c85 Merge pull request #994 from jjlin/help-text
Update startup banner to direct usage/config questions to the forum
2020-05-13 22:34:30 +02:00
Daniel García
aee1ea032b Merge pull request #989 from theycallmesteve/update_responses
Update responses
2020-05-13 22:34:16 +02:00
Daniel García
484e82fb9f Merge pull request #988 from theycallmesteve/rename_functions
Rename functions
2020-05-13 22:34:06 +02:00
Jeremy Lin
322a08edfb Update startup banner to direct usage/config questions to the forum 2020-05-13 12:29:47 -07:00
theycallmesteve
08afc312c3 Add missing items to profileOrganization response model 2020-05-08 13:39:17 -04:00
theycallmesteve
5571a5d8ed Update post_keys to return a keys response model 2020-05-08 13:38:49 -04:00
theycallmesteve
6a8c65493f Rename collection_user_details to collection_read_only to reflect the response model 2020-05-08 13:37:40 -04:00
theycallmesteve
dfdf4473ea Rename to_json_list to to_json_provder to reflect the response model 2020-05-08 13:36:35 -04:00
Daniel García
8bbbff7567 Merge pull request #987 from theycallmesteve/global_domains
GlobalEquivalentDomains updates from upstream bitwarden
2020-05-08 01:04:10 +02:00
theycallmesteve
42e37ebea1 Apply upstream global domain values and whitespace fixes 2020-05-07 18:05:17 -04:00
theycallmesteve
632f4d5453 Whitespace fixes 2020-05-07 18:02:37 -04:00
Daniel García
6c5e35ce5c Change the mails content types to more closely match what we sent before 2020-05-07 00:51:46 +02:00
Daniel García
4ff15f6dc2 Merge pull request #978 from AltiUP/patch-1
Delete the call to the map file
2020-05-03 22:30:06 +02:00
Daniel García
ec8028aef2 Merge pull request #979 from jjlin/admin-redirect
Use absolute URIs for admin page redirects
2020-05-03 22:27:09 +02:00
Daniel García
63cbd9ef9c Update lettre to latest master 2020-05-03 17:41:53 +02:00
Daniel García
9cca64003a Remove unused dependency and simple feature, update dependencies and fix some clippy lints 2020-05-03 17:24:51 +02:00
Jeremy Lin
819d5e2dc8 Use absolute URIs for admin page redirects
This is technically required per RFC 2616 (HTTP/1.1); some proxies will
rewrite a plain `/admin` path to an unexpected URL otherwise.
2020-05-01 00:31:47 -07:00
Christophe Gherardi
3b06ab296b Delete the call to the map file
The file bootstrap.css.map is missing, the reference can be deleted.
2020-04-30 19:41:58 +02:00
Daniel García
0de52c6c99 Merge pull request #957 from jjlin/domain-whitelist
Domain whitelist cleanup and fixes
2020-04-18 12:08:48 +02:00
Daniel García
e3b00b59a7 Initial support for soft deletes 2020-04-17 22:35:27 +02:00
Daniel García
5a390a973f Merge pull request #966 from BlackDex/issue-965
Fixed issue #965
2020-04-15 17:15:59 +02:00
BlackDex
1ee8e44912 Fixed issue #965
PostgreSQL updates/inserts ignored None/null values.
This is nice for new entries, but not for updates.
Added derive option to allways add these none/null values for Option<>
variables.

This solves issue #965
2020-04-15 16:49:33 +02:00
Jeremy Lin
86685c1cd2 Ensure email domain comparison is case-insensitive 2020-04-11 14:51:36 -07:00
Daniel García
e3feba2a2c Merge pull request #960 from jjlin/admin-token
Warn on empty `ADMIN_TOKEN` instead of bailing out
2020-04-11 23:34:37 +02:00
Jeremy Lin
0a68de6c24 Warn on empty ADMIN_TOKEN instead of bailing out
The admin page will still be disabled.

Fixes #849.
2020-04-09 20:55:08 -07:00
Daniel García
4be8dae626 Make web vault show a more informative error when browsers block WebCrypto in insecure contexts and update dependencies 2020-04-09 22:54:31 +02:00
Jeremy Lin
e4d08836e2 Make org owner invitations respect the email domain whitelist
This closes a loophole where org owners can invite new users from any domain.
2020-04-09 01:51:05 -07:00
Jeremy Lin
c2a324e5da Clean up domain whitelist logic
* Make `SIGNUPS_DOMAINS_WHITELIST` override the `SIGNUPS_ALLOWED` setting.
  Otherwise, a common pitfall is to set `SIGNUPS_DOMAINS_WHITELIST` without
  realizing that `SIGNUPS_ALLOWED=false` must also be set.

* Whitespace is now accepted in `SIGNUPS_DOMAINS_WHITELIST`. That is,
  `foo.com, bar.com` is now equivalent to `foo.com,bar.com`.

* Add validation on `SIGNUPS_DOMAINS_WHITELIST`. For example, `foo.com,`
  is rejected as containing an empty token.
2020-04-09 01:42:27 -07:00
Daniel García
77f95146d6 Merge pull request #956 from jjlin/duo
Fix Duo auth failure with non-lowercased email addresses
2020-04-08 08:43:24 +02:00
Jeremy Lin
6cd8512bbd Fix Duo auth failure with non-lowercased email addresses 2020-04-07 20:40:51 -07:00
Daniel García
843604c9e7 Merge pull request #939 from jjlin/attachment-size
Fix attachment size limit calculation
2020-03-31 12:56:49 +02:00
Jeremy Lin
7407b8326a Fix attachment size limit calculation
The config values (in KB) need to be converted to bytes when comparing
against total attachment sizes.
2020-03-31 02:30:28 -07:00
Daniel García
adf47827c9 Make sure the data field is always returned, otherwise the mobile apps seem to have issues 2020-03-30 22:19:50 +02:00
Daniel García
5471088e93 Merge pull request #933 from jjlin/dockerfiles
Rebuild Dockerfiles to match latest Dockerfile.j2 template
2020-03-27 17:45:10 +01:00
Daniel García
4e85a1dee1 Update web vault to 2.13.2 2020-03-27 17:44:10 +01:00
Daniel García
ec60839064 Merge pull request #932 from jjlin/ws-fix
Fix WebSocket notifications
2020-03-27 08:38:54 +01:00
Jeremy Lin
d4bfa1a189 Rebuild Dockerfiles to match latest Dockerfile.j2 template
Picks up a couple of missed changes from b837348b and ccf6ee79.
2020-03-26 20:10:33 -07:00
Jeremy Lin
862d401077 Fix WebSocket notifications
Ignore a missing `id` query param; it's unclear what this ID represents,
but it wasn't being used in the existing bitwarden_rs code, and no longer
seems to be sent in the latest versions of the official clients.
2020-03-26 19:26:44 -07:00
Daniel García
255a06382d Merge pull request #928 from jjlin/healthcheck
Healthcheck fixes/optimizations
2020-03-26 21:13:31 +01:00
Jeremy Lin
bbb0484d03 Healthcheck fixes/optimizations
* Switch healthcheck interval/timeout from 30s/3s to 60s/10s.
  30s interval is arguably overkill, and 3s timeout is definitely too short
  for lower end machines.
* Use HEALTHCHECK CMD exec form to avoid superfluous `sh` invocations.
* Add `--silent --show-error` flags to curl call to avoid progress meter being
  shown in healthcheck logs.
2020-03-25 20:13:36 -07:00
Daniel García
93346bc05d Merge pull request #927 from jjlin/healthcheck
Update healthcheck script to handle alternate base dir
2020-03-25 22:21:08 +01:00
Jeremy Lin
fdf50f0064 Update healthcheck script to handle alternate base dir 2020-03-24 20:00:35 -07:00
Daniel García
ccf6ee79d0 Update dependencies, mainly diesel and sqlite 2020-03-24 20:36:19 +01:00
Daniel García
91dd19473d Merge pull request #922 from jjlin/device-push-token
Handle `devicePushToken`
2020-03-23 00:03:10 +01:00
Jeremy Lin
c06162b22f Handle devicePushToken
Mobile push isn't currently supported, but this should get rid of spurious
`Detected unexpected parameter during login: devicepushtoken` warnings.
2020-03-22 15:04:25 -07:00
Daniel García
7a6a3e4160 Set the cargo version and allow changing it during build time with BWRS_VERSION.
Also renamed GIT_VERSION because that's not the only source anymore.
2020-03-22 16:13:34 +01:00
Daniel García
94341f9f3f Fix token error while accepting invite 2020-03-20 10:51:17 +01:00
Daniel García
ff19fb3426 Merge pull request #919 from BlackDex/issue-908
Fixed issue #908
2020-03-19 18:11:47 +01:00
BlackDex
baac8d9627 Fixed issue #908
The organization uuid is most of the time within the uri path as a
parameter. But sometimes it only is there as a query value.

This fix checks both, and returns the uuid when possible.
2020-03-19 17:37:10 +01:00
BlackDex
669b101e6a Fixing issue #908
Sometimes an org-uuid is not within the path but in a query value,
This fixes the check for that.
2020-03-19 16:50:47 +01:00
Daniel García
935f38692f Merge pull request #918 from dani-garcia/revert-901-feature/opportunistic_tls
Revert "Use opportunistic TLS in SMTP connections"
2020-03-19 13:58:00 +01:00
Daniel García
d2d9fb08cc Revert "Use opportunistic TLS in SMTP connections" 2020-03-19 13:56:53 +01:00
Daniel García
b85d548879 Merge pull request #916 from BlackDex/issue-759
Fixing issue #759 by disabling Foreign Key Checks.
2020-03-18 18:48:08 +01:00
BlackDex
35f30088b2 Fixing issue #759 by disabling Foreign Key Checks.
During migrations some queries are out of order regarding to foreign
keys.
Because of this the migrations fail when the sql database has this
enforced by default.
Turning of this check during the migrations will fix this and this is
only per session.
2020-03-18 18:11:11 +01:00
Daniel García
dce054e632 Merge pull request #912 from ymage/openssl_as_default
Fix alpine build with openssl crate as default
2020-03-16 23:02:07 +01:00
Ymage
ba725e1c25 Make openssl crate as default (non feature-flipped) 2020-03-16 22:39:10 +01:00
Ymage
b837348b25 Build as static 2020-03-16 22:34:59 +01:00
Daniel García
7d9c7017c9 Merge pull request #911 from BlackDex/upgrade-rocket
Upgrade rocket
2020-03-16 18:17:17 +01:00
Daniel García
d6b9b8bf0c Merge pull request #876 from BlackDex/log-panics
Make panics logable (as warn)
2020-03-16 18:16:49 +01:00
BlackDex
bd09fe1a3d Updated code so backtraces are logged also. 2020-03-16 17:53:22 +01:00
BlackDex
bcbe6177b8 Merge branch 'master' of https://github.com/dani-garcia/bitwarden_rs into log-panics 2020-03-16 17:19:27 +01:00
BlackDex
9b1d07365e Updated ring
Some small changes to match the updated ring package.
2020-03-16 16:39:20 +01:00
BlackDex
37b212427c Updated jsonwebtoken
Updated to the latest version of jsonwebtoken.
Some small code changes to match the new versions.
2020-03-16 16:38:00 +01:00
BlackDex
078234d8b3 Small change for rocket compatibilty 2020-03-16 16:36:44 +01:00
BlackDex
3ce0c3d1a5 Update dependencies
Primarily updating rocket, which needed some dependencies

Latest versions of:
 - ring
 - time
 - jsonwebtoken
 - yubico
 - rocket (git)
2020-03-16 16:32:33 +01:00
Daniel García
2ee07ea1d8 Fix empty data when cloning cipher 2020-03-15 17:26:34 +01:00
Daniel García
40c339db9b Fix postgres policies, second try 2020-03-14 23:53:12 +01:00
Daniel García
402c1cd06c Merge pull request #906 from BlackDex/upgrade-reqwest
Updated reqwest to the latest version.
2020-03-14 23:35:52 +01:00
Daniel García
819f340f39 Fix issue with postgres 2020-03-14 23:35:34 +01:00
BlackDex
1b4b40c95d Updated reqwest to the latest version.
- Use the blocking client (no async).
- Disabled gzip.
- use_sys_proxy is now default.
2020-03-14 23:12:45 +01:00
Daniel García
afd9f4e278 Allow the smtp mechanism to be provided without quotes and all lowercase 2020-03-14 22:31:41 +01:00
Daniel García
47a9461f39 Merge pull request #903 from TheBinaryLoop/patch-1
Updated domains with new values vualt
2020-03-14 14:41:39 +01:00
Daniel García
c6f64d8368 Merge pull request #901 from sleweke/feature/opportunistic_tls
Use opportunistic TLS in SMTP connections
2020-03-14 14:41:00 +01:00
Daniel García
edabf19ddf Update vault to 2.13.1 2020-03-14 14:40:06 +01:00
Daniel García
a30d5f4cf9 Fix cloning issues 2020-03-14 14:08:57 +01:00
Daniel García
3fa78e7bb1 Initial version of policies 2020-03-14 13:32:28 +01:00
Lukas Eßmann
a8a7e4f9a5 Updated domains with new values vualt
Added domains from official vault.bitwarden.com
2020-03-13 20:08:52 +01:00
Samuel Leweke
5d3b765a23 Use opportunistic TLS in SMTP connections
If SSL is disabled, the SMTP ClientSecurity of the lettre crate
defaults to None, that is, an insecure connection. This is changed to
Opportunistic, which uses TLS if available. If TLS is not available,
the insecure connection is used (i.e., this change is backward
compatible).
2020-03-12 11:40:52 +01:00
BlackDex
7439aeb63e Make panics logable (as warn)
panic!()'s only appear on stderr, this makes tracking down some strange
issues harder with the usage of docker since stderr does not get logged
into the bitwarden.log file. This change logs the message to stdout and
the logfile when activated.
2020-02-25 14:10:52 +01:00
108 changed files with 5520 additions and 4082 deletions

View File

@@ -185,6 +185,7 @@
# SMTP_FROM_NAME=Bitwarden_RS
# SMTP_PORT=587
# SMTP_SSL=true
# SMTP_EXPLICIT_TLS=true # N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851)
# SMTP_USERNAME=username
# SMTP_PASSWORD=password
# SMTP_AUTH_MECHANISM="Plain"

1750
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,10 @@ mysql = ["diesel/mysql", "diesel_migrations/mysql"]
postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "libsqlite3-sys"]
# Enable unstable features, requires nightly
# Currently only used to enable rusts official ip support
unstable = []
[target."cfg(not(windows))".dependencies]
syslog = "4.0.1"
@@ -26,10 +30,10 @@ rocket = { version = "0.5.0-dev", features = ["tls"], default-features = false }
rocket_contrib = "0.5.0-dev"
# HTTP client
reqwest = "0.9.24"
reqwest = { version = "0.10.6", features = ["blocking", "json"] }
# multipart/form-data support
multipart = { version = "0.16.1", features = ["server"], default-features = false }
multipart = { version = "0.17.0", features = ["server"], default-features = false }
# WebSockets library
ws = "0.9.1"
@@ -41,73 +45,71 @@ rmpv = "0.4.4"
chashmap = "2.2.2"
# A generic serialization/deserialization framework
serde = "1.0.104"
serde_derive = "1.0.104"
serde_json = "1.0.48"
serde = "1.0.114"
serde_derive = "1.0.114"
serde_json = "1.0.56"
# Logging
log = "0.4.8"
fern = { version = "0.6.0", features = ["syslog-4"] }
# A safe, extensible ORM and Query builder
diesel = { version = "1.4.3", features = [ "chrono", "r2d2"] }
diesel = { version = "1.4.5", features = [ "chrono", "r2d2"] }
diesel_migrations = "1.4.0"
# Bundled SQLite
libsqlite3-sys = { version = "0.16.0", features = ["bundled"], optional = true }
libsqlite3-sys = { version = "0.17.3", features = ["bundled"], optional = true }
# Crypto library
ring = "0.14.6"
ring = "0.16.15"
# UUID generation
uuid = { version = "0.8.1", features = ["v4"] }
# Date and time library for Rust
chrono = "0.4.11"
# Date and time libraries
chrono = "0.4.13"
chrono-tz = "0.5.2"
time = "0.2.16"
# TOTP library
oath = "0.10.2"
# Data encoding library
data-encoding = "2.2.0"
data-encoding = "2.2.1"
# JWT library
jsonwebtoken = "6.0.1"
jsonwebtoken = "7.2.0"
# U2F library
u2f = "0.2.0"
# Yubico Library
yubico = { version = "0.7.1", features = ["online-tokio"], default-features = false }
yubico = { version = "0.9.0", features = ["online-tokio"], default-features = false }
# A `dotenv` implementation for Rust
dotenv = { version = "0.15.0", default-features = false }
# Lazy initialization
once_cell = "1.3.1"
# More derives
derive_more = "0.99.3"
once_cell = "1.4.0"
# Numerical libraries
num-traits = "0.2.11"
num-traits = "0.2.12"
num-derive = "0.3.0"
# Email libraries
lettre = "0.10.0-pre"
lettre = { version = "0.10.0-alpha.1", features = ["smtp-transport", "builder", "serde", "native-tls", "hostname"], default-features = false }
native-tls = "0.2.4"
quoted_printable = "0.4.2"
# Template library
handlebars = { version = "3.0.1", features = ["dir_source"] }
handlebars = { version = "3.2.1", features = ["dir_source"] }
# For favicon extraction from main website
soup = "0.5.0"
regex = "1.3.4"
regex = "1.3.9"
data-url = "0.1.0"
# Used by U2F, JWT and Postgres
openssl = "0.10.28"
openssl = "0.10.30"
# URL encoding library
percent-encoding = "2.1.0"
@@ -115,15 +117,15 @@ percent-encoding = "2.1.0"
idna = "0.2.0"
# CLI argument parsing
structopt = "0.3.11"
structopt = "0.3.15"
# Logging panics to logfile instead stderr only
backtrace = "0.3.50"
[patch.crates-io]
# Use newest ring
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'b95b6765e1cc8be7c1e7eaef8a9d9ad940b0ac13' }
rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'b95b6765e1cc8be7c1e7eaef8a9d9ad940b0ac13' }
# Use git version for timeout fix #706
lettre = { git = 'https://github.com/lettre/lettre', rev = '245c600c82ee18b766e8729f005ff453a55dce34' }
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' }
rocket_contrib = { git = 'https://github.com/SergioBenitez/Rocket', rev = '1010f6a2a88fac899dec0cd2f642156908038a53' }
# For favicon extraction from main website
data-url = { git = 'https://github.com/servo/rust-url', package="data-url", rev = '7f1bd6ce1c2fde599a757302a843a60e714c5f72' }

View File

@@ -1,4 +1,5 @@
use std::process::Command;
use std::env;
fn main() {
#[cfg(all(feature = "sqlite", feature = "mysql"))]
@@ -10,8 +11,13 @@ fn main() {
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!("You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite");
read_git_info().ok();
if let Ok(version) = env::var("BWRS_VERSION") {
println!("cargo:rustc-env=BWRS_VERSION={}", version);
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);
} else {
read_git_info().ok();
}
}
fn run(args: &[&str]) -> Result<String, std::io::Error> {
@@ -54,14 +60,16 @@ fn read_git_info() -> Result<(), std::io::Error> {
} else {
format!("{}-{}", last_tag, rev_short)
};
println!("cargo:rustc-env=GIT_VERSION={}", version);
println!("cargo:rustc-env=BWRS_VERSION={}", version);
println!("cargo:rustc-env=CARGO_PKG_VERSION={}", version);
// To access these values, use:
// env!("GIT_EXACT_TAG")
// env!("GIT_LAST_TAG")
// env!("GIT_BRANCH")
// env!("GIT_REV")
// env!("GIT_VERSION")
// env!("BWRS_VERSION")
Ok(())
}

View File

@@ -9,13 +9,13 @@
{% elif "amd64" in target_file %}
{% set runtime_stage_base_image = "debian:buster-slim" %}
{% set package_arch_name = "" %}
{% elif "aarch64" in target_file %}
{% elif "arm64v8" in target_file %}
{% set runtime_stage_base_image = "balenalib/aarch64-debian:buster" %}
{% set package_arch_name = "arm64" %}
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
{% set runtime_stage_base_image = "balenalib/rpi-debian:buster" %}
{% set package_arch_name = "armel" %}
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
{% set runtime_stage_base_image = "balenalib/armv7hf-debian:buster" %}
{% set package_arch_name = "armhf" %}
{% endif %}
@@ -27,17 +27,17 @@
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
####################### VAULT BUILD IMAGE #######################
{% set vault_image_hash = "sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c" %}
{% set vault_image_hash = "sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c" %}
{% raw %}
# This hash is extracted from the docker web-vault builds and it's prefered over a simple tag because it's immutable.
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
{% endraw %}
FROM bitwardenrs/web-vault@{{ vault_image_hash }} as vault
@@ -71,8 +71,9 @@ RUN rustup set profile minimal
{% if "alpine" in target_file %}
ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s'
{% elif "aarch64" in target_file or "armv" in target_file %}
{% elif "arm32" in target_file or "arm64" in target_file %}
# Install required build libs for {{ package_arch_name }} architecture.
RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
/etc/apt/sources.list.d/deb-src.list \
@@ -84,7 +85,7 @@ RUN sed 's/^deb/deb-src/' /etc/apt/sources.list > \
libc6-dev{{ package_arch_prefix }}
{% endif -%}
{% if "aarch64" in target_file %}
{% if "arm64v8" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
@@ -96,7 +97,7 @@ RUN apt-get update \
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
@@ -108,19 +109,7 @@ RUN apt-get update \
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% elif "armv6" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
gcc-arm-linux-gnueabihf \
&& mkdir -p ~/.cargo \
&& echo '[target.armv7-unknown-linux-gnueabihf]' >> ~/.cargo/config \
&& echo 'linker = "arm-linux-gnueabihf-gcc"' >> ~/.cargo/config
ENV CARGO_HOME "/root/.cargo"
ENV USER "root"
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
RUN apt-get update \
&& apt-get install -y \
--no-install-recommends \
@@ -161,17 +150,17 @@ COPY ./Cargo.* ./
COPY ./rust-toolchain ./rust-toolchain
COPY ./build.rs ./build.rs
{% if "aarch64" in target_file %}
{% if "arm64v8" in target_file %}
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"
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
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"
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
ENV CC_armv7_unknown_linux_gnueabihf="/usr/bin/arm-linux-gnueabihf-gcc"
ENV CROSS_COMPILE="1"
ENV OPENSSL_INCLUDE_DIR="/usr/include/arm-linux-gnueabihf"
@@ -181,13 +170,13 @@ ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf"
{% if "alpine" in target_file %}
RUN rustup target add x86_64-unknown-linux-musl
{% elif "aarch64" in target_file %}
{% elif "arm64v8" in target_file %}
RUN rustup target add aarch64-unknown-linux-gnu
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
RUN rustup target add arm-unknown-linux-gnueabi
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
RUN rustup target add armv7-unknown-linux-gnueabihf
{% endif %}
# Builds your dependencies and removes the
@@ -207,11 +196,11 @@ RUN touch src/main.rs
# your actual source files being built
{% if "amd64" in target_file %}
RUN cargo build --features ${DB} --release
{% elif "aarch64" in target_file %}
{% elif "arm64v8" in target_file %}
RUN cargo build --features ${DB} --release --target=aarch64-unknown-linux-gnu
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
RUN cargo build --features ${DB} --release --target=arm-unknown-linux-gnueabi
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-gnueabihf
{% endif %}
@@ -276,20 +265,21 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
{% if "alpine" in target_file %}
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
{% elif "aarch64" in target_file %}
{% elif "arm64v8" in target_file %}
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
{% elif "armv6" in target_file %}
{% elif "arm32v6" in target_file %}
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
{% elif "armv7" in target_file %}
{% elif "arm32v7" in target_file %}
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
{% else %}
COPY --from=build app/target/release/bitwarden_rs .
{% endif %}
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

3
docker/README.md Normal file
View File

@@ -0,0 +1,3 @@
The arch-specific directory names follow the arch identifiers used by the Docker official images:
https://github.com/docker-library/official-images/blob/master/README.md#architectures-other-than-amd64

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -92,10 +92,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build app/target/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,16 +10,16 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# Musl build image for statically compiled binary
FROM clux/muslrust:nightly-2019-12-19 as build
FROM clux/muslrust:nightly-2020-03-09 as build
# set mysql backend
ARG DB=mysql
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
RUN rustup set profile minimal
ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s'
# Install MySQL package
RUN apt-get update && apt-get install -y \
@@ -94,10 +95,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -92,10 +92,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build app/target/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,16 +10,16 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# Musl build image for statically compiled binary
FROM clux/muslrust:nightly-2019-12-19 as build
FROM clux/muslrust:nightly-2020-03-09 as build
# set postgresql backend
ARG DB=postgresql
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
RUN rustup set profile minimal
ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s'
# Install PostgreSQL package
RUN apt-get update && apt-get install -y \
@@ -94,10 +95,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -86,10 +86,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build app/target/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,16 +10,16 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# Musl build image for statically compiled binary
FROM clux/muslrust:nightly-2019-12-19 as build
FROM clux/muslrust:nightly-2020-03-09 as build
# set sqlite as default for DB ARG for backward compatibility
ARG DB=sqlite
@@ -31,6 +31,7 @@ ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 TZ=UTC TERM=xterm-256color
RUN rustup set profile minimal
ENV USER "root"
ENV RUSTFLAGS='-C link-arg=-s'
# Creates a dummy project used to grab dependencies
RUN USER=root cargo new --bin /app
@@ -88,10 +89,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/x86_64-unknown-linux-musl/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -124,10 +124,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -118,10 +118,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/arm-unknown-linux-gnueabi/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -123,10 +123,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -117,10 +117,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/armv7-unknown-linux-gnueabihf/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -124,10 +124,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

View File

@@ -10,12 +10,12 @@
# It can be viewed in multiple ways:
# - From the https://hub.docker.com/repository/docker/bitwardenrs/web-vault/tags page, click the tag name and the digest should be there.
# - From the console, with the following commands:
# docker pull bitwardenrs/web-vault:v2.12.0e
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.12.0e
#
# docker pull bitwardenrs/web-vault:v2.15.1
# docker image inspect --format "{{.RepoDigests}}" bitwardenrs/web-vault:v2.15.1
#
# - To do the opposite, and get the tag from the hash, you can do:
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c
FROM bitwardenrs/web-vault@sha256:ce56b3f5e538351411785ac45e9b4b913259c3508b1323d62e8fa0f30717dd1c as vault
# docker image inspect --format "{{.RepoTags}}" bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c
FROM bitwardenrs/web-vault@sha256:afba1e3bded09dc0a6a0dbacb3363ac33b6f122b4b26d3682cafb9115bdf785c as vault
########################## BUILD IMAGE ##########################
# We need to use the Rust build image, because
@@ -118,10 +118,11 @@ COPY Rocket.toml .
COPY --from=vault /web-vault ./web-vault
COPY --from=build /app/target/aarch64-unknown-linux-gnu/release/bitwarden_rs .
COPY docker/healthcheck.sh ./healthcheck.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/start.sh /start.sh
HEALTHCHECK --interval=30s --timeout=3s CMD sh healthcheck.sh || exit 1
HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"]
# Configures the startup!
WORKDIR /
CMD ["/bitwarden_rs"]
CMD ["/start.sh"]

59
docker/healthcheck.sh Normal file → Executable file
View File

@@ -1,8 +1,53 @@
#!/usr/bin/env sh
#!/bin/sh
if [ -z "$ROCKET_TLS"]
then
curl --fail http://localhost:${ROCKET_PORT:-"80"}/alive || exit 1
else
curl --insecure --fail https://localhost:${ROCKET_PORT:-"80"}/alive || exit 1
fi
# Use the value of the corresponding env var (if present),
# or a default value otherwise.
: ${DATA_FOLDER:="data"}
: ${ROCKET_PORT:="80"}
CONFIG_FILE="${DATA_FOLDER}"/config.json
# Given a config key, return the corresponding config value from the
# config file. If the key doesn't exist, return an empty string.
get_config_val() {
local key="$1"
# Extract a line of the form:
# "domain": "https://bw.example.com/path",
grep "\"${key}\":" "${CONFIG_FILE}" |
# To extract just the value (https://bw.example.com/path), delete:
# (1) everything up to and including the first ':',
# (2) whitespace and '"' from the front,
# (3) ',' and '"' from the back.
sed -e 's/[^:]\+://' -e 's/^[ "]\+//' -e 's/[,"]\+$//'
}
# Extract the base path from a domain URL. For example:
# - `` -> ``
# - `https://bw.example.com` -> ``
# - `https://bw.example.com/` -> ``
# - `https://bw.example.com/path` -> `/path`
# - `https://bw.example.com/multi/path` -> `/multi/path`
get_base_path() {
echo "$1" |
# Delete:
# (1) everything up to and including '://',
# (2) everything up to '/',
# (3) trailing '/' from the back.
sed -e 's|.*://||' -e 's|[^/]\+||' -e 's|/*$||'
}
# Read domain URL from config.json, if present.
if [ -r "${CONFIG_FILE}" ]; then
domain="$(get_config_val 'domain')"
if [ -n "${domain}" ]; then
# config.json 'domain' overrides the DOMAIN env var.
DOMAIN="${domain}"
fi
fi
base_path="$(get_base_path "${DOMAIN}")"
if [ -n "${ROCKET_TLS}" ]; then
s='s'
fi
curl --insecure --fail --silent --show-error \
"http${s}://localhost:${ROCKET_PORT}${base_path}/alive" || exit 1

15
docker/start.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
if [ -r /etc/bitwarden_rs.sh ]; then
. /etc/bitwarden_rs.sh
fi
if [ -d /etc/bitwarden_rs.d ]; then
for f in /etc/bitwarden_rs.d/*.sh; do
if [ -r $f ]; then
. $f
fi
done
fi
exec /bitwarden_rs "${@}"

20
hooks/README.md Normal file
View File

@@ -0,0 +1,20 @@
The hooks in this directory are used to create multi-arch images using Docker Hub automated builds.
Docker Hub hooks provide these predefined [environment variables](https://docs.docker.com/docker-hub/builds/advanced/#environment-variables-for-building-and-testing):
* `SOURCE_BRANCH`: the name of the branch or the tag that is currently being tested.
* `SOURCE_COMMIT`: the SHA1 hash of the commit being tested.
* `COMMIT_MSG`: the message from the commit being tested and built.
* `DOCKER_REPO`: the name of the Docker repository being built.
* `DOCKERFILE_PATH`: the dockerfile currently being built.
* `DOCKER_TAG`: the Docker repository tag being built.
* `IMAGE_NAME`: the name and tag of the Docker repository being built. (This variable is a combination of `DOCKER_REPO:DOCKER_TAG`.)
The current multi-arch image build relies on the original bitwarden_rs Dockerfiles, which use cross-compilation for architectures other than `amd64`, and don't yet support all arch/database/OS combinations. However, cross-compilation is much faster than QEMU-based builds (e.g., using `docker buildx`). This situation may need to be revisited at some point.
## References
* https://docs.docker.com/docker-hub/builds/advanced/
* https://docs.docker.com/engine/reference/commandline/manifest/
* https://www.docker.com/blog/multi-arch-build-and-images-the-simple-way/
* https://success.docker.com/article/how-do-i-authenticate-with-the-v2-api

30
hooks/arches.sh Normal file
View File

@@ -0,0 +1,30 @@
# The default Debian-based SQLite images support these arches.
#
# Other images (Alpine-based, or with other database backends) currently
# support only a subset of these.
arches=(
amd64
arm32v6
arm32v7
arm64v8
)
case "${DOCKER_REPO}" in
*-mysql)
db=mysql
arches=(amd64)
;;
*-postgresql)
db=postgresql
arches=(amd64)
;;
*)
db=sqlite
;;
esac
if [[ "${DOCKER_TAG}" == *alpine ]]; then
# The Alpine build currently only works for amd64.
os_suffix=.alpine
arches=(amd64)
fi

14
hooks/build Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
echo ">>> Building images..."
source ./hooks/arches.sh
set -ex
for arch in "${arches[@]}"; do
docker build \
-t "${DOCKER_REPO}:${DOCKER_TAG}-${arch}" \
-f docker/${arch}/${db}/Dockerfile${os_suffix} \
.
done

96
hooks/push Executable file
View File

@@ -0,0 +1,96 @@
#!/bin/bash
echo ">>> Pushing images..."
export DOCKER_CLI_EXPERIMENTAL=enabled
declare -A annotations=(
[amd64]="--os linux --arch amd64"
[arm32v6]="--os linux --arch arm --variant v6"
[arm32v7]="--os linux --arch arm --variant v7"
[arm64v8]="--os linux --arch arm64 --variant v8"
)
source ./hooks/arches.sh
set -ex
declare -A images
for arch in ${arches[@]}; do
images[$arch]="${DOCKER_REPO}:${DOCKER_TAG}-${arch}"
done
# Push the images that were just built; manifest list creation fails if the
# images (manifests) referenced don't already exist in the Docker registry.
for image in "${images[@]}"; do
docker push "${image}"
done
manifest_lists=("${DOCKER_REPO}:${DOCKER_TAG}")
# If the Docker tag starts with a version number, assume the latest release is
# being pushed. Add an extra manifest (`latest` or `alpine`, as appropriate)
# to make it easier for users to track the latest release.
if [[ "${DOCKER_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
if [[ "${DOCKER_TAG}" == *alpine ]]; then
manifest_lists+=(${DOCKER_REPO}:alpine)
else
manifest_lists+=(${DOCKER_REPO}:latest)
fi
fi
for manifest_list in "${manifest_lists[@]}"; do
# Create the (multi-arch) manifest list of arch-specific images.
docker manifest create ${manifest_list} ${images[@]}
# Make sure each image manifest is annotated with the correct arch info.
# Docker does not auto-detect the arch of each cross-compiled image, so
# everything would appear as `linux/amd64` otherwise.
for arch in "${arches[@]}"; do
docker manifest annotate ${annotations[$arch]} ${manifest_list} ${images[$arch]}
done
# Push the manifest list.
docker manifest push --purge ${manifest_list}
done
# Avoid logging credentials and tokens.
set +ex
# Delete the arch-specific tags, if credentials for doing so are available.
# Note that `DOCKER_PASSWORD` must be the actual user password. Passing a JWT
# obtained using a personal access token results in a 403 error with
# {"detail": "access to the resource is forbidden with personal access token"}
if [[ -z "${DOCKER_USERNAME}" || -z "${DOCKER_PASSWORD}" ]]; then
exit 0
fi
# Given a JSON input on stdin, extract the string value associated with the
# specified key. This avoids an extra dependency on a tool like `jq`.
extract() {
local key="$1"
# Extract "<key>":"<val>" (assumes key/val won't contain double quotes).
# The colon may have whitespace on either side.
grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]\+\"" |
# Extract just <val> by deleting the last '"', and then greedily deleting
# everything up to '"'.
sed -e 's/"$//' -e 's/.*"//'
}
echo ">>> Getting API token..."
jwt=$(curl -sS -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"${DOCKER_USERNAME}\",\"password\": \"${DOCKER_PASSWORD}\"}" \
"https://hub.docker.com/v2/users/login" |
extract 'token')
# Strip the registry portion from `index.docker.io/user/repo`.
repo="${DOCKER_REPO#*/}"
for arch in ${arches[@]}; do
tag="${DOCKER_TAG}-${arch}"
echo ">>> Deleting '${repo}:${tag}'..."
curl -sS -X DELETE \
-H "Authorization: Bearer ${jwt}" \
"https://hub.docker.com/v2/repositories/${repo}/tags/${tag}/"
done

View File

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

View File

@@ -0,0 +1,9 @@
CREATE TABLE org_policies (
uuid CHAR(36) NOT NULL PRIMARY KEY,
org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid),
atype INTEGER NOT NULL,
enabled BOOLEAN NOT NULL,
data TEXT NOT NULL,
UNIQUE (org_uuid, atype)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE ciphers
ADD COLUMN
deleted_at DATETIME;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users_collections
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE;

View File

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

View File

@@ -0,0 +1,9 @@
CREATE TABLE org_policies (
uuid CHAR(36) NOT NULL PRIMARY KEY,
org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid),
atype INTEGER NOT NULL,
enabled BOOLEAN NOT NULL,
data TEXT NOT NULL,
UNIQUE (org_uuid, atype)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE ciphers
ADD COLUMN
deleted_at TIMESTAMP;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users_collections
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE;

View File

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

View File

@@ -0,0 +1,9 @@
CREATE TABLE org_policies (
uuid TEXT NOT NULL PRIMARY KEY,
org_uuid TEXT NOT NULL REFERENCES organizations (uuid),
atype INTEGER NOT NULL,
enabled BOOLEAN NOT NULL,
data TEXT NOT NULL,
UNIQUE (org_uuid, atype)
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE ciphers
ADD COLUMN
deleted_at DATETIME;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users_collections
ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT 0; -- FALSE

View File

@@ -1 +1 @@
nightly-2020-03-09
nightly-2020-07-11

View File

@@ -1,29 +1,35 @@
use once_cell::sync::Lazy;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::process::Command;
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::{
http::{Cookie, Cookies, SameSite},
request::{self, FlashMessage, Form, FromRequest, Request},
response::{content::Html, Flash, Redirect},
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::{backup_database, models::*, DbConn};
use crate::error::Error;
use crate::mail;
use crate::CONFIG;
use crate::{
api::{ApiResult, EmptyResult, JsonResult},
auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp},
config::ConfigBuilder,
db::{backup_database, models::*, DbConn},
error::{Error, MapResult},
mail,
util::get_display_size,
CONFIG,
};
pub fn routes() -> Vec<Route> {
if CONFIG.admin_token().is_none() && !CONFIG.disable_admin_token() {
if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() {
return routes![admin_disabled];
}
routes![
admin_login,
get_users,
get_users_json,
post_admin_login,
admin_page,
invite_user,
@@ -36,6 +42,9 @@ pub fn routes() -> Vec<Route> {
delete_config,
backup_db,
test_smtp,
users_overview,
organizations_overview,
diagnostics,
]
}
@@ -51,12 +60,20 @@ 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");
const VERSION: Option<&str> = option_env!("BWRS_VERSION");
fn admin_path() -> String {
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
}
/// Used for `Location` response headers, which must specify an absolute URI
/// (see https://tools.ietf.org/html/rfc2616#section-14.30).
fn admin_url() -> String {
// Don't use CONFIG.domain() directly, since the user may want to keep a
// trailing slash there, particularly when running under a subpath.
format!("{}{}{}", CONFIG.domain_origin(), CONFIG.domain_path(), ADMIN_PATH)
}
#[get("/", rank = 2)]
fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> {
// If there is an error, show it
@@ -81,7 +98,7 @@ fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -
if !_validate_token(&data.token) {
error!("Invalid admin token. IP: {}", ip.ip);
Err(Flash::error(
Redirect::to(admin_path()),
Redirect::to(admin_url()),
"Invalid admin token, please try again.",
))
} else {
@@ -91,13 +108,13 @@ fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -
let cookie = Cookie::build(COOKIE_NAME, jwt)
.path(admin_path())
.max_age(chrono::Duration::minutes(20))
.max_age(time::Duration::minutes(20))
.same_site(SameSite::Strict)
.http_only(true)
.finish();
cookies.add(cookie);
Ok(Redirect::to(admin_path()))
Ok(Redirect::to(admin_url()))
}
}
@@ -112,7 +129,9 @@ fn _validate_token(token: &str) -> bool {
struct AdminTemplateData {
page_content: String,
version: Option<&'static str>,
users: Vec<Value>,
users: Option<Vec<Value>>,
organizations: Option<Vec<Value>>,
diagnostics: Option<Value>,
config: Value,
can_backup: bool,
logged_in: bool,
@@ -120,15 +139,59 @@ struct AdminTemplateData {
}
impl AdminTemplateData {
fn new(users: Vec<Value>) -> Self {
fn new() -> Self {
Self {
page_content: String::from("admin/page"),
page_content: String::from("admin/settings"),
version: VERSION,
users,
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
users: None,
organizations: None,
diagnostics: None,
}
}
fn users(users: Vec<Value>) -> Self {
Self {
page_content: String::from("admin/users"),
version: VERSION,
users: Some(users),
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
organizations: None,
diagnostics: None,
}
}
fn organizations(organizations: Vec<Value>) -> Self {
Self {
page_content: String::from("admin/organizations"),
version: VERSION,
organizations: Some(organizations),
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
users: None,
diagnostics: None,
}
}
fn diagnostics(diagnostics: Value) -> Self {
Self {
page_content: String::from("admin/diagnostics"),
version: VERSION,
organizations: None,
config: CONFIG.prepare_json(),
can_backup: *CAN_BACKUP,
logged_in: true,
urlpath: CONFIG.domain_path(),
users: None,
diagnostics: Some(diagnostics),
}
}
@@ -138,11 +201,8 @@ impl AdminTemplateData {
}
#[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()?;
fn admin_page(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
let text = AdminTemplateData::new().render()?;
Ok(Html(text))
}
@@ -174,10 +234,9 @@ fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> Empt
#[post("/test/smtp", data = "<data>")]
fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
let data: InviteData = data.into_inner();
let email = data.email.clone();
if CONFIG.mail_enabled() {
mail::send_test(&email)
mail::send_test(&data.email)
} else {
err!("Mail is not enabled")
}
@@ -186,34 +245,42 @@ fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
#[get("/logout")]
fn logout(mut cookies: Cookies) -> Result<Redirect, ()> {
cookies.remove(Cookie::named(COOKIE_NAME));
Ok(Redirect::to(admin_path()))
Ok(Redirect::to(admin_url()))
}
#[get("/users")]
fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult {
fn get_users_json(_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)))
}
#[get("/users/overview")]
fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter()
.map(|u| {
let mut usr = u.to_json(&conn);
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn));
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn));
usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn) as i32));
usr
}).collect();
let text = AdminTemplateData::users(users_json).render()?;
Ok(Html(text))
}
#[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"),
};
let user = User::find_by_uuid(&uuid, &conn).map_res("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"),
};
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?;
Device::delete_all_by_user(&user.uuid, &conn)?;
user.reset_security_stamp();
@@ -222,11 +289,7 @@ fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
#[post("/users/<uuid>/remove-2fa")]
fn remove_2fa(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"),
};
let mut user = User::find_by_uuid(&uuid, &conn).map_res("User doesn't exist")?;
TwoFactor::delete_all_by_user(&user.uuid, &conn)?;
user.totp_recover = None;
user.save(&conn)
@@ -237,6 +300,109 @@ fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
User::update_all_revisions(&conn)
}
#[get("/organizations/overview")]
fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let organizations = Organization::get_all(&conn);
let organizations_json: Vec<Value> = organizations.iter().map(|o| {
let mut org = o.to_json();
org["user_count"] = json!(UserOrganization::count_by_org(&o.uuid, &conn));
org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn));
org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn));
org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn) as i32));
org
}).collect();
let text = AdminTemplateData::organizations(organizations_json).render()?;
Ok(Html(text))
}
#[derive(Deserialize)]
struct WebVaultVersion {
version: String,
}
#[derive(Deserialize)]
struct GitRelease {
tag_name: String,
}
#[derive(Deserialize)]
struct GitCommit {
sha: String,
}
fn get_github_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
use reqwest::{blocking::Client, header::USER_AGENT};
use std::time::Duration;
let github_api = Client::builder().build()?;
Ok(
github_api.get(url)
.timeout(Duration::from_secs(10))
.header(USER_AGENT, "Bitwarden_RS")
.send()?
.error_for_status()?
.json::<T>()?
)
}
#[get("/diagnostics")]
fn diagnostics(_token: AdminToken, _conn: DbConn) -> ApiResult<Html<String>> {
use std::net::ToSocketAddrs;
use chrono::prelude::*;
use crate::util::read_file_string;
let vault_version_path = format!("{}/{}", CONFIG.web_vault_folder(), "version.json");
let vault_version_str = read_file_string(&vault_version_path)?;
let web_vault_version: WebVaultVersion = serde_json::from_str(&vault_version_str)?;
let github_ips = ("github.com", 0).to_socket_addrs().map(|mut i| i.next());
let (dns_resolved, dns_ok) = match github_ips {
Ok(Some(a)) => (a.ip().to_string(), true),
_ => ("Could not resolve domain name.".to_string(), false),
};
// If the DNS Check failed, do not even attempt to check for new versions since we were not able to resolve github.com
let (latest_release, latest_commit, latest_web_build) = if dns_ok {
(
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bitwarden_rs/releases/latest") {
Ok(r) => r.tag_name,
_ => "-".to_string()
},
match get_github_api::<GitCommit>("https://api.github.com/repos/dani-garcia/bitwarden_rs/commits/master") {
Ok(mut c) => {
c.sha.truncate(8);
c.sha
},
_ => "-".to_string()
},
match get_github_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") {
Ok(r) => r.tag_name.trim_start_matches('v').to_string(),
_ => "-".to_string()
},
)
} else {
("-".to_string(), "-".to_string(), "-".to_string())
};
// Run the date check as the last item right before filling the json.
// This should ensure that the time difference between the browser and the server is as minimal as possible.
let dt = Utc::now();
let server_time = dt.format("%Y-%m-%d %H:%M:%S").to_string();
let diagnostics_json = json!({
"dns_resolved": dns_resolved,
"server_time": server_time,
"web_vault_version": web_vault_version.version,
"latest_release": latest_release,
"latest_commit": latest_commit,
"latest_web_build": latest_web_build,
});
let text = AdminTemplateData::diagnostics(diagnostics_json).render()?;
Ok(Html(text))
}
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();

View File

@@ -1,19 +1,15 @@
use chrono::Utc;
use rocket_contrib::json::Json;
use crate::db::models::*;
use crate::db::DbConn;
use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType},
auth::{decode_delete, decode_invite, decode_verify_email, Headers},
crypto,
db::{models::*, DbConn},
mail, CONFIG,
};
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType};
use crate::auth::{decode_delete, decode_invite, decode_verify_email, Headers};
use crate::crypto;
use crate::mail;
use crate::CONFIG;
use rocket::Route;
pub fn routes() -> Vec<Route> {
pub fn routes() -> Vec<rocket::Route> {
routes![
register,
profile,
@@ -68,7 +64,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
let mut user = match User::find_by_mail(&data.Email, &conn) {
Some(user) => {
if !user.password_hash.is_empty() {
if CONFIG.signups_allowed() {
if CONFIG.is_signup_allowed(&data.Email) {
err!("User already exists")
} else {
err!("Registration not allowed or user already exists")
@@ -89,14 +85,17 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
}
user
} else if CONFIG.signups_allowed() {
} else if CONFIG.is_signup_allowed(&data.Email) {
err!("Account with this email already exists")
} else {
err!("Registration not allowed or user already exists")
}
}
None => {
if CONFIG.signups_allowed() || Invitation::take(&data.Email, &conn) || CONFIG.can_signup_user(&data.Email) {
// Order is important here; the invitation check must come first
// because the bitwarden_rs admin can invite anyone, regardless
// of other signup restrictions.
if Invitation::take(&data.Email, &conn) || CONFIG.is_signup_allowed(&data.Email) {
User::new(data.Email.clone())
} else {
err!("Registration not allowed or user already exists")
@@ -207,7 +206,12 @@ fn post_keys(data: JsonUpcase<KeysData>, headers: Headers, conn: DbConn) -> Json
user.public_key = Some(data.PublicKey);
user.save(&conn)?;
Ok(Json(user.to_json(&conn)))
Ok(Json(json!({
"PrivateKey": user.private_key,
"PublicKey": user.public_key,
"Object":"keys"
})))
}
#[derive(Deserialize)]
@@ -371,8 +375,8 @@ fn post_email_token(data: JsonUpcase<EmailTokenData>, headers: Headers, conn: Db
err!("Email already in use");
}
if !CONFIG.signups_allowed() && !CONFIG.can_signup_user(&data.NewEmail) {
err!("Email cannot be changed to this address");
if !CONFIG.is_email_domain_allowed(&data.NewEmail) {
err!("Email domain not allowed");
}
let token = crypto::generate_token(6)?;

View File

@@ -1,26 +1,20 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use rocket::http::ContentType;
use rocket::{request::Form, Data, Route};
use rocket::{http::ContentType, request::Form, Data, Route};
use rocket_contrib::json::Json;
use serde_json::Value;
use multipart::server::save::SavedData;
use multipart::server::{Multipart, SaveResult};
use data_encoding::HEXLOWER;
use multipart::server::{save::SavedData, Multipart, SaveResult};
use crate::db::models::*;
use crate::db::DbConn;
use crate::crypto;
use crate::api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType};
use crate::auth::Headers;
use crate::CONFIG;
use crate::{
api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType},
auth::Headers,
crypto,
db::{models::*, DbConn},
CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![
@@ -49,10 +43,16 @@ pub fn routes() -> Vec<Route> {
put_cipher,
delete_cipher_post,
delete_cipher_post_admin,
delete_cipher_put,
delete_cipher_put_admin,
delete_cipher,
delete_cipher_admin,
delete_cipher_selected,
delete_cipher_selected_post,
delete_cipher_selected_put,
restore_cipher_put,
restore_cipher_put_admin,
restore_cipher_selected,
delete_all,
move_cipher_selected,
move_cipher_selected_put,
@@ -79,6 +79,9 @@ fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult {
let collections = Collection::find_by_user_uuid(&headers.user.uuid, &conn);
let collections_json: Vec<Value> = collections.iter().map(Collection::to_json).collect();
let policies = OrgPolicy::find_by_user(&headers.user.uuid, &conn);
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
let ciphers = Cipher::find_by_user(&headers.user.uuid, &conn);
let ciphers_json: Vec<Value> = ciphers
.iter()
@@ -95,6 +98,7 @@ fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult {
"Profile": user_json,
"Folders": folders_json,
"Collections": collections_json,
"Policies": policies_json,
"Ciphers": ciphers_json,
"Domains": domains_json,
"Object": "sync"
@@ -264,7 +268,10 @@ pub fn update_cipher_from_data(
};
if saved_att.cipher_uuid != cipher.uuid {
err!("Attachment is not owned by the cipher")
// Warn and break here since cloning ciphers provides attachment data but will not be cloned.
// If we error out here it will break the whole cloning and causes empty ciphers to appear.
warn!("Attachment is not owned by the cipher");
break;
}
saved_att.akey = Some(attachment.Key);
@@ -599,10 +606,13 @@ fn share_cipher_by_uuid(
None => err!("Cipher doesn't exist"),
};
let mut shared_to_collection = false;
match data.Cipher.OrganizationId.clone() {
None => err!("Organization id not provided"),
// If we don't get an organization ID, we don't do anything
// No error because this is used when using the Clone functionality
None => {}
Some(organization_uuid) => {
let mut shared_to_collection = false;
for uuid in &data.CollectionIds {
match Collection::find_by_uuid_and_org(uuid, &organization_uuid, &conn) {
None => err!("Invalid collection ID provided"),
@@ -616,19 +626,20 @@ fn share_cipher_by_uuid(
}
}
}
update_cipher_from_data(
&mut cipher,
data.Cipher,
&headers,
shared_to_collection,
&conn,
&nt,
UpdateType::CipherUpdate,
)?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
}
}
};
update_cipher_from_data(
&mut cipher,
data.Cipher,
&headers,
shared_to_collection,
&conn,
&nt,
UpdateType::CipherUpdate,
)?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
}
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
@@ -648,7 +659,7 @@ fn post_attachment(
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
err_discard!("Cipher is not write accessible", data)
}
let mut params = content_type.params();
let boundary_pair = params.next().expect("No boundary provided");
let boundary = boundary_pair.1;
@@ -656,8 +667,8 @@ fn post_attachment(
let size_limit = if let Some(ref user_uuid) = cipher.user_uuid {
match CONFIG.user_attachment_limit() {
Some(0) => err_discard!("Attachments are disabled", data),
Some(limit) => {
let left = limit - Attachment::size_by_user(user_uuid, &conn);
Some(limit_kb) => {
let left = (limit_kb * 1024) - Attachment::size_by_user(user_uuid, &conn);
if left <= 0 {
err_discard!("Attachment size limit reached! Delete some files to open space", data)
}
@@ -668,8 +679,8 @@ fn post_attachment(
} else if let Some(ref org_uuid) = cipher.organization_uuid {
match CONFIG.org_attachment_limit() {
Some(0) => err_discard!("Attachments are disabled", data),
Some(limit) => {
let left = limit - Attachment::size_by_org(org_uuid, &conn);
Some(limit_kb) => {
let left = (limit_kb * 1024) - Attachment::size_by_org(org_uuid, &conn);
if left <= 0 {
err_discard!("Attachment size limit reached! Delete some files to open space", data)
}
@@ -752,11 +763,7 @@ fn post_attachment_admin(
post_attachment(uuid, data, content_type, headers, conn, nt)
}
#[post(
"/ciphers/<uuid>/attachment/<attachment_id>/share",
format = "multipart/form-data",
data = "<data>"
)]
#[post("/ciphers/<uuid>/attachment/<attachment_id>/share", format = "multipart/form-data", data = "<data>")]
fn post_attachment_share(
uuid: String,
attachment_id: String,
@@ -810,48 +817,62 @@ fn delete_attachment_admin(
#[post("/ciphers/<uuid>/delete")]
fn delete_cipher_post(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
}
#[post("/ciphers/<uuid>/delete-admin")]
fn delete_cipher_post_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
}
#[put("/ciphers/<uuid>/delete")]
fn delete_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, true, &nt)
}
#[put("/ciphers/<uuid>/delete-admin")]
fn delete_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, true, &nt)
}
#[delete("/ciphers/<uuid>")]
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
}
#[delete("/ciphers/<uuid>/admin")]
fn delete_cipher_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_cipher_by_uuid(&uuid, &headers, &conn, &nt)
_delete_cipher_by_uuid(&uuid, &headers, &conn, false, &nt)
}
#[delete("/ciphers", data = "<data>")]
fn delete_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let data: Value = data.into_inner().data;
let uuids = match data.get("Ids") {
Some(ids) => match ids.as_array() {
Some(ids) => ids.iter().filter_map(Value::as_str),
None => err!("Posted ids field is not an array"),
},
None => err!("Request missing ids field"),
};
for uuid in uuids {
if let error @ Err(_) = _delete_cipher_by_uuid(uuid, &headers, &conn, &nt) {
return error;
};
}
Ok(())
_delete_multiple_ciphers(data, headers, conn, false, nt)
}
#[post("/ciphers/delete", data = "<data>")]
fn delete_cipher_selected_post(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
delete_cipher_selected(data, headers, conn, nt)
_delete_multiple_ciphers(data, headers, conn, false, nt)
}
#[put("/ciphers/delete", data = "<data>")]
fn delete_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_delete_multiple_ciphers(data, headers, conn, true, nt)
}
#[put("/ciphers/<uuid>/restore")]
fn restore_cipher_put(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
}
#[put("/ciphers/<uuid>/restore-admin")]
fn restore_cipher_put_admin(uuid: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_restore_cipher_by_uuid(&uuid, &headers, &conn, &nt)
}
#[put("/ciphers/restore", data = "<data>")]
fn restore_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
_restore_multiple_ciphers(data, headers, conn, nt)
}
#[derive(Deserialize)]
@@ -965,8 +986,8 @@ fn delete_all(
}
}
fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> EmptyResult {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, soft_delete: bool, nt: &Notify) -> EmptyResult {
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
};
@@ -975,11 +996,74 @@ fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Not
err!("Cipher can't be deleted by user")
}
cipher.delete(&conn)?;
if soft_delete {
cipher.deleted_at = Some(chrono::Utc::now().naive_utc());
cipher.save(&conn)?;
} else {
cipher.delete(&conn)?;
}
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
Ok(())
}
fn _delete_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, soft_delete: bool, nt: Notify) -> EmptyResult {
let data: Value = data.into_inner().data;
let uuids = match data.get("Ids") {
Some(ids) => match ids.as_array() {
Some(ids) => ids.iter().filter_map(Value::as_str),
None => err!("Posted ids field is not an array"),
},
None => err!("Request missing ids field"),
};
for uuid in uuids {
if let error @ Err(_) = _delete_cipher_by_uuid(uuid, &headers, &conn, soft_delete, &nt) {
return error;
};
}
Ok(())
}
fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn, nt: &Notify) -> EmptyResult {
let mut cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher,
None => err!("Cipher doesn't exist"),
};
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
err!("Cipher can't be restored by user")
}
cipher.deleted_at = None;
cipher.save(&conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn));
Ok(())
}
fn _restore_multiple_ciphers(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
let data: Value = data.into_inner().data;
let uuids = match data.get("Ids") {
Some(ids) => match ids.as_array() {
Some(ids) => ids.iter().filter_map(Value::as_str),
None => err!("Posted ids field is not an array"),
},
None => err!("Request missing ids field"),
};
for uuid in uuids {
if let error @ Err(_) = _restore_cipher_by_uuid(uuid, &headers, &conn, &nt) {
return error;
};
}
Ok(())
}
fn _delete_cipher_attachment_by_id(
uuid: &str,
attachment_id: &str,

View File

@@ -1,15 +1,13 @@
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::db::models::*;
use crate::db::DbConn;
use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType},
auth::Headers,
db::{models::*, DbConn},
};
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType};
use crate::auth::Headers;
use rocket::Route;
pub fn routes() -> Vec<Route> {
pub fn routes() -> Vec<rocket::Route> {
routes![
get_folders,
get_folder,
@@ -50,7 +48,6 @@ fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> JsonResult {
#[derive(Deserialize)]
#[allow(non_snake_case)]
pub struct FolderData {
pub Name: String,
}

View File

@@ -2,7 +2,7 @@ mod accounts;
mod ciphers;
mod folders;
mod organizations;
pub(crate) mod two_factor;
pub mod two_factor;
pub fn routes() -> Vec<Route> {
let mut mod_routes = routes![
@@ -29,14 +29,15 @@ pub fn routes() -> Vec<Route> {
// Move this somewhere else
//
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::api::{EmptyResult, JsonResult, JsonUpcase};
use crate::auth::Headers;
use crate::db::DbConn;
use crate::error::Error;
use crate::{
api::{EmptyResult, JsonResult, JsonUpcase},
auth::Headers,
db::DbConn,
error::Error,
};
#[put("/devices/identifier/<uuid>/clear-token")]
fn clear_device_token(uuid: String) -> EmptyResult {
@@ -146,10 +147,10 @@ fn hibp_breach(username: String) -> JsonResult {
username
);
use reqwest::{header::USER_AGENT, Client};
use reqwest::{blocking::Client, header::USER_AGENT};
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
let hibp_client = Client::builder().use_sys_proxy().build()?;
let hibp_client = Client::builder().build()?;
let res = hibp_client
.get(&url)

View File

@@ -1,16 +1,14 @@
use rocket::request::Form;
use rocket::Route;
use num_traits::FromPrimitive;
use rocket::{request::Form, Route};
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::api::{
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType,
use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType},
auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders},
db::{models::*, DbConn},
mail, CONFIG,
};
use crate::auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders};
use crate::db::models::*;
use crate::db::DbConn;
use crate::mail;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![
@@ -45,6 +43,10 @@ pub fn routes() -> Vec<Route> {
delete_user,
post_delete_user,
post_org_import,
list_policies,
list_policies_token,
get_policy,
put_policy,
]
}
@@ -369,7 +371,7 @@ fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders,
.map(|col_user| {
UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn)
.unwrap()
.to_json_collection_user_details(col_user.read_only)
.to_json_user_access_restrictions(&col_user)
})
.collect();
@@ -403,7 +405,9 @@ fn put_collection_users(
continue;
}
CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, &conn)?;
CollectionUser::save(&user.user_uuid, &coll_id,
d.ReadOnly, d.HidePasswords,
&conn)?;
}
Ok(())
@@ -447,6 +451,7 @@ fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonRe
struct CollectionData {
Id: String,
ReadOnly: bool,
HidePasswords: bool,
}
#[derive(Deserialize)]
@@ -480,7 +485,11 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
let user = match User::find_by_mail(&email, &conn) {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User email does not exist: {}", email))
err!(format!("User does not exist: {}", email))
}
if !CONFIG.is_email_domain_allowed(&email) {
err!("Email domain not eligible for invitations")
}
if !CONFIG.mail_enabled() {
@@ -514,7 +523,9 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"),
Some(collection) => {
CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn)?;
CollectionUser::save(&user.uuid, &collection.uuid,
col.ReadOnly, col.HidePasswords,
&conn)?;
}
}
}
@@ -769,7 +780,9 @@ fn edit_user(
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"),
Some(collection) => {
CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.ReadOnly, &conn)?;
CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid,
col.ReadOnly, col.HidePasswords,
&conn)?;
}
}
}
@@ -830,22 +843,13 @@ struct RelationsData {
fn post_org_import(
query: Form<OrgIdData>,
data: JsonUpcase<ImportData>,
headers: Headers,
headers: AdminHeaders,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
let data: ImportData = data.into_inner().data;
let org_id = query.into_inner().organization_id;
let org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
Some(user) => user,
None => err!("User is not part of the organization"),
};
if org_user.atype < UserOrgType::Admin {
err!("Only admins or owners can import into an organization")
}
// Read and create the collections
let collections: Vec<_> = data
.Collections
@@ -866,6 +870,8 @@ fn post_org_import(
relations.push((relation.Key, relation.Value));
}
let headers: Headers = headers.into();
// Read and create the ciphers
let ciphers: Vec<_> = data
.Ciphers
@@ -901,3 +907,83 @@ fn post_org_import(
let mut user = headers.user;
user.update_revision(&conn)
}
#[get("/organizations/<org_id>/policies")]
fn list_policies(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let policies = OrgPolicy::find_by_org(&org_id, &conn);
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
Ok(Json(json!({
"Data": policies_json,
"Object": "list",
"ContinuationToken": null
})))
}
#[get("/organizations/<org_id>/policies/token?<token>")]
fn list_policies_token(org_id: String, token: String, conn: DbConn) -> JsonResult {
let invite = crate::auth::decode_invite(&token)?;
let invite_org_id = match invite.org_id {
Some(invite_org_id) => invite_org_id,
None => err!("Invalid token"),
};
if invite_org_id != org_id {
err!("Token doesn't match request organization");
}
// TODO: We receive the invite token as ?token=<>, validate it contains the org id
let policies = OrgPolicy::find_by_org(&org_id, &conn);
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
Ok(Json(json!({
"Data": policies_json,
"Object": "list",
"ContinuationToken": null
})))
}
#[get("/organizations/<org_id>/policies/<pol_type>")]
fn get_policy(org_id: String, pol_type: i32, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
Some(pt) => pt,
None => err!("Invalid policy type"),
};
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type, &conn) {
Some(p) => p,
None => OrgPolicy::new(org_id, pol_type_enum, "{}".to_string()),
};
Ok(Json(policy.to_json()))
}
#[derive(Deserialize)]
struct PolicyData {
enabled: bool,
#[serde(rename = "type")]
_type: i32,
data: Value,
}
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
fn put_policy(org_id: String, pol_type: i32, data: Json<PolicyData>, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let data: PolicyData = data.into_inner();
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
Some(pt) => pt,
None => err!("Invalid policy type"),
};
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type, &conn) {
Some(p) => p,
None => OrgPolicy::new(org_id, pol_type_enum, "{}".to_string()),
};
policy.enabled = data.enabled;
policy.data = serde_json::to_string(&data.data)?;
policy.save(&conn)?;
Ok(Json(policy.to_json()))
}

View File

@@ -2,13 +2,16 @@ use data_encoding::BASE32;
use rocket::Route;
use rocket_contrib::json::Json;
use crate::api::core::two_factor::_generate_recover_code;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{
models::{TwoFactor, TwoFactorType},
DbConn,
use crate::{
api::{
core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData,
},
auth::{ClientIp, Headers},
crypto,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
};
pub use crate::config::CONFIG;
@@ -20,6 +23,7 @@ pub fn routes() -> Vec<Route> {
activate_authenticator_put,
]
}
#[post("/two-factor/get-authenticator", data = "<data>")]
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data;
@@ -53,7 +57,12 @@ struct EnableAuthenticatorData {
}
#[post("/two-factor/authenticator", data = "<data>")]
fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
fn activate_authenticator(
data: JsonUpcase<EnableAuthenticatorData>,
headers: Headers,
ip: ClientIp,
conn: DbConn,
) -> JsonResult {
let data: EnableAuthenticatorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let key = data.Key;
@@ -76,7 +85,7 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
}
// Validate the token provided with the key, and save new twofactor
validate_totp_code(&user.uuid, token, &key.to_uppercase(), &conn)?;
validate_totp_code(&user.uuid, token, &key.to_uppercase(), &ip, &conn)?;
_generate_recover_code(&mut user, &conn);
@@ -88,20 +97,31 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
}
#[put("/two-factor/authenticator", data = "<data>")]
fn activate_authenticator_put(data: JsonUpcase<EnableAuthenticatorData>, headers: Headers, conn: DbConn) -> JsonResult {
activate_authenticator(data, headers, conn)
fn activate_authenticator_put(
data: JsonUpcase<EnableAuthenticatorData>,
headers: Headers,
ip: ClientIp,
conn: DbConn,
) -> JsonResult {
activate_authenticator(data, headers, ip, conn)
}
pub fn validate_totp_code_str(user_uuid: &str, totp_code: &str, secret: &str, conn: &DbConn) -> EmptyResult {
pub fn validate_totp_code_str(
user_uuid: &str,
totp_code: &str,
secret: &str,
ip: &ClientIp,
conn: &DbConn,
) -> EmptyResult {
let totp_code: u64 = match totp_code.parse() {
Ok(code) => code,
_ => err!("TOTP code is not a number"),
};
validate_totp_code(user_uuid, totp_code, secret, &conn)
validate_totp_code(user_uuid, totp_code, secret, ip, &conn)
}
pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, conn: &DbConn) -> EmptyResult {
pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, ip: &ClientIp, conn: &DbConn) -> EmptyResult {
use oath::{totp_raw_custom_time, HashType};
let decoded_secret = match BASE32.decode(secret.as_bytes()) {
@@ -143,11 +163,22 @@ pub fn validate_totp_code(user_uuid: &str, totp_code: u64, secret: &str, conn: &
twofactor.save(&conn)?;
return Ok(());
} else if generated == totp_code && time_step <= twofactor.last_used as i64 {
warn!("This or a TOTP code within {} steps back and forward has already been used!", steps);
err!(format!("Invalid TOTP code! Server time: {}", current_time.format("%F %T UTC")));
warn!(
"This or a TOTP code within {} steps back and forward has already been used!",
steps
);
err!(format!(
"Invalid TOTP code! Server time: {} IP: {}",
current_time.format("%F %T UTC"),
ip.ip
));
}
}
// Else no valide code received, deny access
err!(format!("Invalid TOTP code! Server time: {}", current_time.format("%F %T UTC")));
err!(format!(
"Invalid TOTP code! Server time: {} IP: {}",
current_time.format("%F %T UTC"),
ip.ip
));
}

View File

@@ -2,18 +2,18 @@ use chrono::Utc;
use data_encoding::BASE64;
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json;
use crate::api::core::two_factor::_generate_recover_code;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{
models::{TwoFactor, TwoFactorType, User},
DbConn,
use crate::{
api::{core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase, PasswordData},
auth::Headers,
crypto,
db::{
models::{TwoFactor, TwoFactorType, User},
DbConn,
},
error::MapResult,
CONFIG,
};
use crate::error::MapResult;
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![get_duo, activate_duo, activate_duo_put,]
@@ -21,9 +21,9 @@ pub fn routes() -> Vec<Route> {
#[derive(Serialize, Deserialize)]
struct DuoData {
host: String,
ik: String,
sk: String,
host: String, // Duo API hostname
ik: String, // integration key
sk: String, // secret key
}
impl DuoData {
@@ -187,9 +187,10 @@ fn activate_duo_put(data: JsonUpcase<EnableDuoData>, headers: Headers, conn: DbC
fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult {
const AGENT: &str = "bitwarden_rs:Duo/1.0 (Rust)";
use reqwest::{header::*, Client, Method};
use reqwest::{blocking::Client, header::*, Method};
use std::str::FromStr;
// https://duo.com/docs/authapi#api-details
let url = format!("https://{}{}", &data.host, path);
let date = Utc::now().to_rfc2822();
let username = &data.ik;
@@ -268,6 +269,10 @@ fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64
}
pub fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
// email is as entered by the user, so it needs to be normalized before
// comparison with auth_user below.
let email = &email.to_lowercase();
let split: Vec<&str> = response.split(':').collect();
if split.len() != 2 {
err!("Invalid response length");

View File

@@ -1,21 +1,18 @@
use chrono::{Duration, NaiveDateTime, Utc};
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json;
use crate::api::core::two_factor::_generate_recover_code;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{
models::{TwoFactor, TwoFactorType},
DbConn,
use crate::{
api::{core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, PasswordData},
auth::Headers,
crypto,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
error::{Error, MapResult},
mail, CONFIG,
};
use crate::error::Error;
use crate::mail;
use crate::CONFIG;
use chrono::{Duration, NaiveDateTime, Utc};
use std::ops::Add;
pub fn routes() -> Vec<Route> {
routes![get_email, send_email_login, send_email, email,]
@@ -59,7 +56,7 @@ fn send_email_login(data: JsonUpcase<SendEmailLoginData>, conn: DbConn) -> Empty
/// Generate the token, save the data for later verification and send email to user
pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult {
let type_ = TwoFactorType::Email as i32;
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, &conn)?;
let mut twofactor = TwoFactor::find_by_user_and_type(user_uuid, type_, &conn).map_res("Two factor not found")?;
let generated_token = crypto::generate_token(CONFIG.email_token_size())?;
@@ -68,7 +65,7 @@ pub fn send_token(user_uuid: &str, conn: &DbConn) -> EmptyResult {
twofactor.data = twofactor_data.to_json();
twofactor.save(&conn)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
Ok(())
}
@@ -135,7 +132,7 @@ fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, conn: DbConn) -
);
twofactor.save(&conn)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token?)?;
mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?)?;
Ok(())
}
@@ -159,7 +156,7 @@ fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
}
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn)?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).map_res("Two factor not found")?;
let mut email_data = EmailTokenData::from_json(&twofactor.data)?;
@@ -189,7 +186,7 @@ fn email(data: JsonUpcase<EmailData>, headers: Headers, conn: DbConn) -> JsonRes
/// Validate the email code when used as TwoFactor token mechanism
pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &DbConn) -> EmptyResult {
let mut email_data = EmailTokenData::from_json(&data)?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn)?;
let mut twofactor = TwoFactor::find_by_user_and_type(&user_uuid, TwoFactorType::Email as i32, &conn).map_res("Two factor not found")?;
let issued_token = match &email_data.last_token {
Some(t) => t,
_ => err!("No token available"),
@@ -212,7 +209,7 @@ pub fn validate_email_code_str(user_uuid: &str, token: &str, data: &str, conn: &
let date = NaiveDateTime::from_timestamp(email_data.token_sent, 0);
let max_time = CONFIG.email_expiration_time() as i64;
if date.add(Duration::seconds(max_time)) < Utc::now().naive_utc() {
if date + Duration::seconds(max_time) < Utc::now().naive_utc() {
err!("Token has expired")
}

View File

@@ -1,22 +1,23 @@
use data_encoding::BASE32;
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json;
use serde_json::Value;
use crate::api::{JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{
models::{TwoFactor, User},
DbConn,
use crate::{
api::{JsonResult, JsonUpcase, NumberOrString, PasswordData},
auth::Headers,
crypto,
db::{
models::{TwoFactor, User},
DbConn,
},
};
pub(crate) mod authenticator;
pub(crate) mod duo;
pub(crate) mod email;
pub(crate) mod u2f;
pub(crate) mod yubikey;
pub mod authenticator;
pub mod duo;
pub mod email;
pub mod u2f;
pub mod yubikey;
pub fn routes() -> Vec<Route> {
let mut routes = routes![
@@ -39,7 +40,7 @@ pub fn routes() -> Vec<Route> {
#[get("/two-factor")]
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_list).collect();
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect();
Ok(Json(json!({
"Data": twofactors_json,

View File

@@ -1,21 +1,26 @@
use once_cell::sync::Lazy;
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json;
use serde_json::Value;
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
use u2f::protocol::{Challenge, U2f};
use u2f::register::Registration;
use crate::api::core::two_factor::_generate_recover_code;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::db::{
models::{TwoFactor, TwoFactorType},
DbConn,
use u2f::{
messages::{RegisterResponse, SignResponse, U2fSignRequest},
protocol::{Challenge, U2f},
register::Registration,
};
use crate::{
api::{
core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString,
PasswordData,
},
auth::Headers,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
error::Error,
CONFIG,
};
use crate::error::Error;
use crate::CONFIG;
const U2F_VERSION: &str = "U2F_V2";

View File

@@ -1,19 +1,18 @@
use rocket::Route;
use rocket_contrib::json::Json;
use serde_json;
use serde_json::Value;
use yubico::config::Config;
use yubico::verify;
use yubico::{config::Config, verify};
use crate::api::core::two_factor::_generate_recover_code;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, PasswordData};
use crate::auth::Headers;
use crate::db::{
models::{TwoFactor, TwoFactorType},
DbConn,
use crate::{
api::{core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, PasswordData},
auth::Headers,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
error::{Error, MapResult},
CONFIG,
};
use crate::error::{Error, MapResult};
use crate::CONFIG;
pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]

View File

@@ -1,23 +1,17 @@
use std::{
fs::{create_dir_all, remove_file, symlink_metadata, File},
io::prelude::*,
net::{IpAddr, ToSocketAddrs},
time::{Duration, SystemTime},
};
use once_cell::sync::Lazy;
use std::fs::{create_dir_all, remove_file, symlink_metadata, File};
use std::io::prelude::*;
use std::net::ToSocketAddrs;
use std::time::{Duration, SystemTime};
use rocket::http::ContentType;
use rocket::response::Content;
use rocket::Route;
use reqwest::{header::HeaderMap, Client, Response, Url};
use rocket::http::Cookie;
use regex::Regex;
use reqwest::{blocking::Client, blocking::Response, header::HeaderMap, Url};
use rocket::{http::ContentType, http::Cookie, response::Content, Route};
use soup::prelude::*;
use crate::error::Error;
use crate::CONFIG;
use crate::util::Cached;
use crate::{error::Error, util::Cached, CONFIG};
pub fn routes() -> Vec<Route> {
routes![icon]
@@ -30,14 +24,17 @@ const ALLOWED_CHARS: &str = "_-.";
static CLIENT: Lazy<Client> = Lazy::new(|| {
// Reuse the client between requests
Client::builder()
.use_sys_proxy()
.gzip(true)
.timeout(Duration::from_secs(CONFIG.icon_download_timeout()))
.default_headers(_header_map())
.build()
.unwrap()
});
static ICON_REL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"icon$|apple.*icon").unwrap());
static ICON_HREF_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$|^data:image.*base64").unwrap());
static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").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("..") {
@@ -66,13 +63,111 @@ fn icon(domain: String) -> Cached<Content<Vec<u8>>> {
Cached::long(Content(icon_type, get_icon(&domain)))
}
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
#[cfg(not(feature = "unstable"))]
fn is_global(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
// check if this address is 192.0.0.9 or 192.0.0.10. These addresses are the only two
// globally routable addresses in the 192.0.0.0/24 range.
if u32::from(ip) == 0xc0000009 || u32::from(ip) == 0xc000000a {
return true;
}
!ip.is_private()
&& !ip.is_loopback()
&& !ip.is_link_local()
&& !ip.is_broadcast()
&& !ip.is_documentation()
&& !(ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000))
&& !(ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0)
&& !(ip.octets()[0] & 240 == 240 && !ip.is_broadcast())
&& !(ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18)
// Make sure the address is not in 0.0.0.0/8
&& ip.octets()[0] != 0
}
IpAddr::V6(ip) => {
if ip.is_multicast() && ip.segments()[0] & 0x000f == 14 {
true
} else {
!ip.is_multicast()
&& !ip.is_loopback()
&& !((ip.segments()[0] & 0xffc0) == 0xfe80)
&& !((ip.segments()[0] & 0xfe00) == 0xfc00)
&& !ip.is_unspecified()
&& !((ip.segments()[0] == 0x2001) && (ip.segments()[1] == 0xdb8))
}
}
}
}
#[cfg(feature = "unstable")]
fn is_global(ip: IpAddr) -> bool {
ip.is_global()
}
/// These are some tests to check that the implementations match
/// The IPv4 can be all checked in 5 mins or so and they are correct as of nightly 2020-07-11
/// The IPV6 can't be checked in a reasonable time, so we check about ten billion random ones, so far correct
/// Note that the is_global implementation is subject to change as new IP RFCs are created
///
/// To run while showing progress output:
/// cargo test --features sqlite,unstable -- --nocapture --ignored
#[cfg(test)]
#[cfg(feature = "unstable")]
mod tests {
use super::*;
#[test]
#[ignore]
fn test_ipv4_global() {
for a in 0..u8::MAX {
println!("Iter: {}/255", a);
for b in 0..u8::MAX {
for c in 0..u8::MAX {
for d in 0..u8::MAX {
let ip = IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d));
assert_eq!(ip.is_global(), is_global(ip))
}
}
}
}
}
#[test]
#[ignore]
fn test_ipv6_global() {
use ring::rand::{SecureRandom, SystemRandom};
let mut v = [0u8; 16];
let rand = SystemRandom::new();
for i in 0..1_000 {
println!("Iter: {}/1_000", i);
for _ in 0..10_000_000 {
rand.fill(&mut v).expect("Error generating random values");
let ip = IpAddr::V6(std::net::Ipv6Addr::new(
(v[14] as u16) << 8 | v[15] as u16,
(v[12] as u16) << 8 | v[13] as u16,
(v[10] as u16) << 8 | v[11] as u16,
(v[8] as u16) << 8 | v[9] as u16,
(v[6] as u16) << 8 | v[7] as u16,
(v[4] as u16) << 8 | v[5] as u16,
(v[2] as u16) << 8 | v[3] as u16,
(v[0] as u16) << 8 | v[1] as u16,
));
assert_eq!(ip.is_global(), is_global(ip))
}
}
}
}
fn check_icon_domain_is_blacklisted(domain: &str) -> bool {
let mut is_blacklisted = CONFIG.icon_blacklist_non_global_ips()
&& (domain, 0)
.to_socket_addrs()
.map(|x| {
for ip_port in x {
if !ip_port.ip().is_global() {
if !is_global(ip_port.ip()) {
warn!("IP {} for domain '{}' is not a global IP!", ip_port.ip(), domain);
return true;
}
@@ -184,7 +279,7 @@ struct Icon {
}
impl Icon {
fn new(priority: u8, href: String) -> Self {
const fn new(priority: u8, href: String) -> Self {
Self { href, priority }
}
}
@@ -215,7 +310,7 @@ fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
let mut cookie_str = String::new();
let resp = get_page(&ssldomain).or_else(|_| get_page(&httpdomain));
if let Ok(mut content) = resp {
if let Ok(content) = resp {
// Extract the URL from the respose in case redirects occured (like @ gitlab.com)
let url = content.url().clone();
@@ -237,14 +332,14 @@ fn get_icon_url(domain: &str) -> Result<(Vec<Icon>, String), Error> {
// 512KB should be more than enough for the HTML, though as we only really need
// the HTML header, it could potentially be reduced even further
let limited_reader = crate::util::LimitedReader::new(&mut content, 512 * 1024);
let limited_reader = content.take(512 * 1024);
let soup = Soup::from_reader(limited_reader)?;
// 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)(\?.*)?$|^data:image.*base64")?) // Only allow specific extensions
.attr("rel", ICON_REL_REGEX.clone()) // Only use icon rels
.attr("href", ICON_HREF_REGEX.clone()) // Only allow specific extensions
.find_all();
// Loop through all the found icons and determine it's priority
@@ -356,7 +451,7 @@ fn parse_sizes(sizes: Option<String>) -> (u16, u16) {
let mut height: u16 = 0;
if let Some(sizes) = sizes {
match Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap().captures(sizes.trim()) {
match ICON_SIZE_REGEX.captures(sizes.trim()) {
None => {}
Some(dimensions) => {
if dimensions.len() >= 3 {

View File

@@ -1,19 +1,22 @@
use chrono::Utc;
use chrono::Local;
use num_traits::FromPrimitive;
use rocket::request::{Form, FormItems, FromForm};
use rocket::Route;
use rocket::{
request::{Form, FormItems, FromForm},
Route,
};
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::api::core::two_factor::email::EmailTokenData;
use crate::api::core::two_factor::{duo, email, yubikey};
use crate::api::{ApiResult, EmptyResult, JsonResult};
use crate::auth::ClientIp;
use crate::db::models::*;
use crate::db::DbConn;
use crate::mail;
use crate::util;
use crate::CONFIG;
use crate::{
api::{
core::two_factor::{duo, email, email::EmailTokenData, yubikey},
ApiResult, EmptyResult, JsonResult,
},
auth::ClientIp,
db::{models::*, DbConn},
error::MapResult,
mail, util, CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![login]
@@ -38,7 +41,7 @@ fn login(data: Form<ConnectData>, conn: DbConn, ip: ClientIp) -> JsonResult {
_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)
_password_login(data, conn, &ip)
}
t => err!("Invalid type", t),
}
@@ -49,10 +52,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
let token = data.refresh_token.unwrap();
// Get device by refresh token
let mut device = match Device::find_by_refresh_token(&token, &conn) {
Some(device) => device,
None => err!("Invalid refresh token"),
};
let mut device = Device::find_by_refresh_token(&token, &conn).map_res("Invalid refresh token")?;
// COMMON
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
@@ -71,7 +71,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult {
})))
}
fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult {
fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope
let scope = data.scope.as_ref().unwrap();
if scope != "api offline_access" {
@@ -97,8 +97,10 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
)
}
let now = Local::now();
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
let now = Utc::now().naive_utc();
let now = now.naive_utc();
if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() > CONFIG.signups_verify_resend_time() as i64 {
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
if resend_limit == 0 || user.login_verify_count < resend_limit {
@@ -127,10 +129,10 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: ClientIp) -> JsonResult
let (mut device, new_device) = get_device(&data, &conn, &user);
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &ip, &conn)?;
if CONFIG.mail_enabled() && new_device {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &device.updated_at, &device.name) {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name) {
error!("Error sending new device email: {:#?}", e);
if CONFIG.require_device_email() {
@@ -197,6 +199,7 @@ fn twofactor_auth(
user_uuid: &str,
data: &ConnectData,
device: &mut Device,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(user_uuid, conn);
@@ -216,8 +219,7 @@ fn twofactor_auth(
let selected_twofactor = twofactors
.into_iter()
.filter(|tf| tf.atype == selected_id && tf.enabled)
.nth(0);
.find(|tf| tf.atype == selected_id && tf.enabled);
use crate::api::core::two_factor as _tf;
use crate::crypto::ct_eq;
@@ -226,7 +228,7 @@ fn twofactor_auth(
let mut remember = data.two_factor_remember.unwrap_or(0);
match TwoFactorType::from_i32(selected_id) {
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, conn)?,
Some(TwoFactorType::Authenticator) => _tf::authenticator::validate_totp_code_str(user_uuid, twofactor_code, &selected_data?, ip, conn)?,
Some(TwoFactorType::U2f) => _tf::u2f::validate_u2f_login(user_uuid, twofactor_code, conn)?,
Some(TwoFactorType::YubiKey) => _tf::yubikey::validate_yubikey_login(twofactor_code, &selected_data?)?,
Some(TwoFactorType::Duo) => _tf::duo::validate_duo_login(data.username.as_ref().unwrap(), twofactor_code, conn)?,
@@ -252,10 +254,7 @@ 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"),
}
tf.map(|t| t.data).map_res("Two factor doesn't exist")
}
fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> ApiResult<Value> {
@@ -347,6 +346,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
Ok(result)
}
// https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
#[derive(Debug, Clone, Default)]
#[allow(non_snake_case)]
struct ConnectData {
@@ -364,6 +364,7 @@ struct ConnectData {
device_identifier: Option<String>,
device_name: Option<String>,
device_type: Option<String>,
device_push_token: Option<String>, // Unused; mobile device push not yet supported.
// Needed for two-factor auth
two_factor_provider: Option<i32>,
@@ -391,6 +392,7 @@ impl<'f> FromForm<'f> for ConnectData {
"deviceidentifier" => form.device_identifier = Some(value),
"devicename" => form.device_name = Some(value),
"devicetype" => form.device_type = Some(value),
"devicepushtoken" => form.device_push_token = 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(),

View File

@@ -1,27 +1,29 @@
mod admin;
pub(crate) mod core;
pub mod core;
mod icons;
mod identity;
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::notifications::routes as notifications_routes;
pub use self::notifications::{start_notification_server, Notify, UpdateType};
pub use self::web::routes as web_routes;
use rocket_contrib::json::Json;
use serde_json::Value;
pub use crate::api::{
admin::routes as admin_routes,
core::routes as core_routes,
icons::routes as icons_routes,
identity::routes as identity_routes,
notifications::routes as notifications_routes,
notifications::{start_notification_server, Notify, UpdateType},
web::routes as web_routes,
};
use crate::util;
// Type aliases for API methods results
type ApiResult<T> = Result<T, crate::error::Error>;
pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
use crate::util;
type JsonUpcase<T> = Json<util::UpCase<T>>;
type JsonUpcaseVec<T> = Json<Vec<util::UpCase<T>>>;

View File

@@ -4,11 +4,12 @@ use rocket::Route;
use rocket_contrib::json::Json;
use serde_json::Value as JsonValue;
use crate::api::{EmptyResult, JsonResult};
use crate::auth::Headers;
use crate::db::DbConn;
use crate::{Error, CONFIG};
use crate::{
api::{EmptyResult, JsonResult},
auth::Headers,
db::DbConn,
Error, CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![negotiate, websockets_err]
@@ -152,15 +153,19 @@ impl WSHandler {
impl Handler for WSHandler {
fn on_open(&mut self, hs: Handshake) -> ws::Result<()> {
// Path == "/notifications/hub?id=<id>==&access_token=<access_token>"
//
// We don't use `id`, and as of around 2020-03-25, the official clients
// no longer seem to pass `id` (only `access_token`).
let path = hs.request.resource();
let (_id, access_token) = match path.split('?').nth(1) {
Some(params) => {
let mut params_iter = params.split('&').take(2);
let params_iter = params.split('&').take(2);
let mut id = None;
let mut access_token = None;
while let Some(val) = params_iter.next() {
for val in params_iter {
if val.starts_with(ID_KEY) {
id = Some(&val[ID_KEY.len()..]);
} else if val.starts_with(ACCESS_TOKEN_KEY) {
@@ -170,10 +175,11 @@ impl Handler for WSHandler {
match (id, access_token) {
(Some(a), Some(b)) => (a, b),
_ => return self.err("Missing id or access token"),
(None, Some(b)) => ("", b), // Ignore missing `id`.
_ => return self.err("Missing access token"),
}
}
None => return self.err("Missing query path"),
None => return self.err("Missing query parameters"),
};
// Validate the user
@@ -256,7 +262,9 @@ impl Factory for WSFactory {
// 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);
if let Some(pos) = user_conn.iter().position(|x| x == &handler.out) {
user_conn.remove(pos);
}
}
}
}

View File

@@ -1,15 +1,10 @@
use std::path::{Path, PathBuf};
use rocket::http::ContentType;
use rocket::response::content::Content;
use rocket::response::NamedFile;
use rocket::Route;
use rocket::{http::ContentType, response::content::Content, response::NamedFile, Route};
use rocket_contrib::json::Json;
use serde_json::Value;
use crate::error::Error;
use crate::util::Cached;
use crate::CONFIG;
use crate::{error::Error, util::Cached, CONFIG};
pub fn routes() -> Vec<Route> {
// If addding more routes here, consider also adding them to
@@ -78,6 +73,7 @@ fn static_files(filename: String) -> Result<Content<&'static [u8]>, Error> {
match filename.as_ref() {
"mail-github.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
"logo-gray.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
"shield-white.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/shield-white.png"))),
"error-x.svg" => Ok(Content(ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
"hibp.png" => Ok(Content(ContentType::PNG, include_bytes!("../static/images/hibp.png"))),

View File

@@ -1,16 +1,19 @@
//
// JWT Handling
//
use crate::util::read_file;
use chrono::{Duration, Utc};
use num_traits::FromPrimitive;
use once_cell::sync::Lazy;
use jsonwebtoken::{self, Algorithm, Header};
use jsonwebtoken::{self, Algorithm, DecodingKey, EncodingKey, Header};
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use crate::error::{Error, MapResult};
use crate::CONFIG;
use crate::{
error::{Error, MapResult},
util::read_file,
CONFIG,
};
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
@@ -31,7 +34,7 @@ static PUBLIC_RSA_KEY: Lazy<Vec<u8>> = Lazy::new(|| match read_file(&CONFIG.publ
});
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
match jsonwebtoken::encode(&JWT_HEADER, claims, &EncodingKey::from_rsa_der(&PRIVATE_RSA_KEY)) {
Ok(token) => token,
Err(e) => panic!("Error encoding jwt {}", e),
}
@@ -50,7 +53,7 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
let token = token.replace(char::is_whitespace, "");
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation)
jsonwebtoken::decode(&token, &DecodingKey::from_rsa_der(&PUBLIC_RSA_KEY), &validation)
.map(|d| d.claims)
.map_res("Error decoding JWT")
}
@@ -212,11 +215,15 @@ pub fn generate_admin_claims() -> AdminJWTClaims {
//
// Bearer token authentication
//
use rocket::request::{self, FromRequest, Request};
use rocket::Outcome;
use rocket::{
request::{self, FromRequest, Request},
Outcome,
};
use crate::db::models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization};
use crate::db::DbConn;
use crate::db::{
models::{Device, User, UserOrgStatus, UserOrgType, UserOrganization},
DbConn,
};
pub struct Headers {
pub host: String,
@@ -306,6 +313,25 @@ pub struct OrgHeaders {
pub org_user_type: UserOrgType,
}
// org_id is usually the second param ("/organizations/<org_id>")
// But there are cases where it is located in a query value.
// First check the param, if this is not a valid uuid, we will try the query value.
fn get_org_id(request: &Request) -> Option<String> {
if let Some(Ok(org_id)) = request.get_param::<String>(1) {
if uuid::Uuid::parse_str(&org_id).is_ok() {
return Some(org_id);
}
}
if let Some(Ok(org_id)) = request.get_query_value::<String>("organizationId") {
if uuid::Uuid::parse_str(&org_id).is_ok() {
return Some(org_id);
}
}
None
}
impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
type Error = &'static str;
@@ -314,9 +340,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
Outcome::Forward(_) => Outcome::Forward(()),
Outcome::Failure(f) => Outcome::Failure(f),
Outcome::Success(headers) => {
// org_id is expected to be the second param ("/organizations/<org_id>")
match request.get_param::<String>(1) {
Some(Ok(org_id)) => {
match get_org_id(request) {
Some(org_id) => {
let conn = match request.guard::<DbConn>() {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB"),
@@ -385,6 +410,16 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminHeaders {
}
}
impl Into<Headers> for AdminHeaders {
fn into(self) -> Headers {
Headers {
host: self.host,
device: self.device,
user: self.user,
}
}
}
pub struct OwnerHeaders {
pub host: String,
pub device: Device,

View File

@@ -1,11 +1,13 @@
use once_cell::sync::Lazy;
use std::process::exit;
use std::sync::RwLock;
use once_cell::sync::Lazy;
use reqwest::Url;
use crate::error::Error;
use crate::util::{get_env, get_env_bool};
use crate::{
error::Error,
util::{get_env, get_env_bool},
};
static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
@@ -112,6 +114,8 @@ macro_rules! make_config {
)+)+
config.domain_set = _domain_set;
config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase();
config
}
}
@@ -133,7 +137,6 @@ macro_rules! make_config {
(inner._env.build(), inner.config.clone())
};
fn _get_form_type(rust_type: &str) -> &'static str {
match rust_type {
"Pass" => "password",
@@ -263,7 +266,7 @@ make_config! {
/// $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
/// Allow new signups |> Controls whether new users can register. Users can be invited by the bitwarden_rs admin even if this is disabled
signups_allowed: bool, true, def, true;
/// Require email verification on signups. This will prevent logins from succeeding until the address has been verified
signups_verify: bool, true, def, false;
@@ -271,9 +274,9 @@ make_config! {
signups_verify_resend_time: u64, true, def, 3_600;
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
signups_verify_resend_limit: u32, true, def, 6;
/// Allow signups only from this list of comma-separated domains
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
signups_domains_whitelist: String, true, def, "".to_string();
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are disabled
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise 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
@@ -393,7 +396,9 @@ make_config! {
/// Json form auth mechanism |> Defaults for ssl is "Plain" and "Login" and nothing for non-ssl connections. Possible values: ["Plain", "Login", "Xoauth2"]
smtp_auth_mechanism: String, true, option;
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
smtp_timeout: u64, true, def, 15;
smtp_timeout: u64, true, def, 15;
/// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters
helo_name: String, true, option;
},
/// Email 2FA Settings
@@ -411,7 +416,9 @@ make_config! {
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
let db_url = cfg.database_url.to_lowercase();
if cfg!(feature = "sqlite") && (db_url.starts_with("mysql:") || db_url.starts_with("postgresql:")) {
if cfg!(feature = "sqlite")
&& (db_url.starts_with("mysql:") || db_url.starts_with("postgresql:") || db_url.starts_with("postgres:"))
{
err!("`DATABASE_URL` is meant for MySQL or Postgres, while this server is meant for SQLite")
}
@@ -419,7 +426,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("`DATABASE_URL` should start with mysql: when using the MySQL server")
}
if cfg!(feature = "postgresql") && !db_url.starts_with("postgresql:") {
if cfg!(feature = "postgresql") && !(db_url.starts_with("postgresql:") || db_url.starts_with("postgres:")) {
err!("`DATABASE_URL` should start with postgresql: when using the PostgreSQL server")
}
@@ -428,9 +435,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
err!("DOMAIN variable needs to contain the protocol (http, https). Use 'http[s]://bw.example.com' instead of 'bw.example.com'");
}
let whitelist = &cfg.signups_domains_whitelist;
if !whitelist.is_empty() && whitelist.split(',').any(|d| d.trim().is_empty()) {
err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens");
}
if let Some(ref token) = cfg.admin_token {
if token.trim().is_empty() && !cfg.disable_admin_token {
err!("`ADMIN_TOKEN` is enabled but has an empty value. To enable the admin page without token, use `DISABLE_ADMIN_TOKEN`")
println!("[WARNING] `ADMIN_TOKEN` is enabled but has an empty value, so the admin page will be disabled.");
println!("[WARNING] To enable the admin page without a token, use `DISABLE_ADMIN_TOKEN`.");
}
}
@@ -551,18 +564,30 @@ impl Config {
self.update_config(builder)
}
pub fn can_signup_user(&self, email: &str) -> bool {
/// Tests whether an email's domain is allowed. A domain is allowed if it
/// is in signups_domains_whitelist, or if no whitelist is set (so there
/// are no domain restrictions in effect).
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
let e: Vec<&str> = email.rsplitn(2, '@').collect();
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
warn!("Failed to parse email address '{}'", email);
return false;
}
let email_domain = e[0].to_lowercase();
let whitelist = self.signups_domains_whitelist();
// Allow signups if the whitelist is empty/not configured
// (it doesn't contain any domains), or if it matches at least
// one domain.
let whitelist_str = self.signups_domains_whitelist();
( whitelist_str.is_empty() && CONFIG.signups_allowed() )|| whitelist_str.split(',').filter(|s| !s.is_empty()).any(|d| d == e[0])
whitelist.is_empty() || whitelist.split(',').any(|d| d.trim() == email_domain)
}
/// Tests whether signup is allowed for an email address, taking into
/// account the signups_allowed and signups_domains_whitelist settings.
pub fn is_signup_allowed(&self, email: &str) -> bool {
if !self.signups_domains_whitelist().is_empty() {
// The whitelist setting overrides the signups_allowed setting.
self.is_email_domain_allowed(email)
} else {
self.signups_allowed()
}
}
pub fn delete_user_config(&self) -> Result<(), Error> {
@@ -617,6 +642,13 @@ impl Config {
}
}
/// Tests whether the admin token is set to a non-empty value.
pub fn is_admin_token_set(&self) -> bool {
let token = self.admin_token();
token.is_some() && !token.unwrap().trim().is_empty()
}
pub fn render_template<T: serde::ser::Serialize>(
&self,
name: &str,
@@ -674,7 +706,10 @@ where
reg!("admin/base");
reg!("admin/login");
reg!("admin/page");
reg!("admin/settings");
reg!("admin/users");
reg!("admin/organizations");
reg!("admin/diagnostics");
// And then load user templates to overwrite the defaults
// Use .hbs extension for the files

View File

@@ -1,12 +1,13 @@
//
// PBKDF2 derivation
//
use crate::error::Error;
use ring::{digest, hmac, pbkdf2};
use std::num::NonZeroU32;
static DIGEST_ALG: &digest::Algorithm = &digest::SHA256;
use ring::{digest, hmac, pbkdf2};
use crate::error::Error;
static DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256;
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
@@ -29,7 +30,7 @@ pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterati
pub fn hmac_sign(key: &str, data: &str) -> String {
use data_encoding::HEXLOWER;
let key = hmac::SigningKey::new(&digest::SHA1, key.as_bytes());
let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_bytes());
let signature = hmac::sign(&key, data.as_bytes());
HEXLOWER.encode(signature.as_ref())

View File

@@ -1,18 +1,14 @@
use std::ops::Deref;
use diesel::r2d2;
use diesel::r2d2::ConnectionManager;
use diesel::{Connection as DieselConnection, ConnectionError};
use rocket::http::Status;
use rocket::request::{self, FromRequest};
use rocket::{Outcome, Request, State};
use crate::error::Error;
use chrono::prelude::*;
use std::process::Command;
use crate::CONFIG;
use chrono::prelude::*;
use diesel::{r2d2, r2d2::ConnectionManager, Connection as DieselConnection, ConnectionError};
use rocket::{
http::Status,
request::{self, FromRequest},
Outcome, Request, State,
};
use crate::{error::Error, CONFIG};
/// An alias to the database connection used
#[cfg(feature = "sqlite")]
@@ -76,7 +72,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<DbConn, ()> {
let pool = request.guard::<State<Pool>>()?;
// https://github.com/SergioBenitez/Rocket/commit/e3c1a4ad3ab9b840482ec6de4200d30df43e357c
let pool = try_outcome!(request.guard::<State<Pool>>());
match pool.get() {
Ok(conn) => Outcome::Success(DbConn(conn)),
Err(_) => Outcome::Failure((Status::ServiceUnavailable, ())),
@@ -85,7 +82,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
}
// For the convenience of using an &DbConn as a &Database.
impl Deref for DbConn {
impl std::ops::Deref for DbConn {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.0

View File

@@ -5,6 +5,7 @@ use crate::CONFIG;
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "attachments"]
#[changeset_options(treat_none_as_null="true")]
#[belongs_to(Cipher, foreign_key = "cipher_uuid")]
#[primary_key(id)]
pub struct Attachment {
@@ -17,7 +18,7 @@ pub struct Attachment {
/// Local methods
impl Attachment {
pub fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32) -> Self {
pub const fn new(id: String, cipher_uuid: String, file_name: String, file_size: i32) -> Self {
Self {
id,
cipher_uuid,
@@ -51,7 +52,6 @@ impl Attachment {
use crate::db::schema::{attachments, ciphers};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;
@@ -130,6 +130,16 @@ impl Attachment {
result.unwrap_or(0)
}
pub fn count_by_user(user_uuid: &str, conn: &DbConn) -> i64 {
attachments::table
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
.filter(ciphers::user_uuid.eq(user_uuid))
.count()
.first::<i64>(&**conn)
.ok()
.unwrap_or(0)
}
pub fn size_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
let result: Option<i64> = attachments::table
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
@@ -140,4 +150,14 @@ impl Attachment {
result.unwrap_or(0)
}
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
attachments::table
.left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid)))
.filter(ciphers::organization_uuid.eq(org_uuid))
.count()
.first(&**conn)
.ok()
.unwrap_or(0)
}
}

View File

@@ -7,6 +7,7 @@ use super::{
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "ciphers"]
#[changeset_options(treat_none_as_null="true")]
#[belongs_to(User, foreign_key = "user_uuid")]
#[belongs_to(Organization, foreign_key = "organization_uuid")]
#[primary_key(uuid)]
@@ -33,6 +34,7 @@ pub struct Cipher {
pub favorite: bool,
pub password_history: Option<String>,
pub deleted_at: Option<NaiveDateTime>,
}
/// Local methods
@@ -57,13 +59,13 @@ impl Cipher {
data: String::new(),
password_history: None,
deleted_at: None,
}
}
}
use crate::db::schema::*;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;
@@ -80,7 +82,28 @@ impl Cipher {
let fields_json = self.fields.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
let password_history_json = self.password_history.as_ref().and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
let mut data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null);
let (read_only, hide_passwords) =
match self.get_access_restrictions(&user_uuid, &conn) {
Some((ro, hp)) => (ro, hp),
None => {
error!("Cipher ownership assertion failure");
(true, true)
},
};
// Get the data or a default empty value to avoid issues with the mobile apps
let mut data_json: Value = serde_json::from_str(&self.data).unwrap_or_else(|_| json!({
"Fields":null,
"Name": self.name,
"Notes":null,
"Password":null,
"PasswordHistory":null,
"PasswordRevisionDate":null,
"Response":null,
"Totp":null,
"Uris":null,
"Username":null
}));
// TODO: ******* Backwards compat start **********
// To remove backwards compatibility, just remove this entire section
@@ -91,15 +114,26 @@ impl Cipher {
}
// TODO: ******* Backwards compat end **********
// There are three types of cipher response models in upstream
// Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order
// of increasing level of detail). bitwarden_rs currently only
// supports the "cipherDetails" type, though it seems like the
// Bitwarden clients will ignore extra fields.
//
// Ref: https://github.com/bitwarden/server/blob/master/src/Core/Models/Api/Response/CipherResponseModel.cs
let mut json_object = json!({
"Object": "cipherDetails",
"Id": self.uuid,
"Type": self.atype,
"RevisionDate": format_date(&self.updated_at),
"DeletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))),
"FolderId": self.get_folder_uuid(&user_uuid, &conn),
"Favorite": self.favorite,
"OrganizationId": self.organization_uuid,
"Attachments": attachments_json,
"OrganizationUseTotp": true,
// This field is specific to the cipherDetails type.
"CollectionIds": self.get_collections(user_uuid, &conn),
"Name": self.name,
@@ -108,8 +142,11 @@ impl Cipher {
"Data": data_json,
"Object": "cipher",
"Edit": true,
// These values are true by default, but can be false if the
// cipher belongs to a collection where the org owner has enabled
// the "Read Only" or "Hide Passwords" restrictions for the user.
"Edit": !read_only,
"ViewPassword": !hide_passwords,
"PasswordHistory": password_history_json,
});
@@ -226,64 +263,78 @@ impl Cipher {
}
}
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
ciphers::table
/// Returns whether this cipher is directly owned by the user.
pub fn is_owned_by_user(&self, user_uuid: &str) -> bool {
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
}
/// Returns whether this cipher is owned by an org in which the user has full access.
pub fn is_in_full_access_org(&self, user_uuid: &str, conn: &DbConn) -> bool {
if let Some(ref org_uuid) = self.organization_uuid {
if let Some(user_org) = UserOrganization::find_by_user_and_org(&user_uuid, &org_uuid, &conn) {
return user_org.has_full_access();
}
}
false
}
/// Returns the user's access restrictions to this cipher. A return value
/// of None means that this cipher does not belong to the user, and is
/// not in any collection the user has access to. Otherwise, the user has
/// access to this cipher, and Some(read_only, hide_passwords) represents
/// the access restrictions.
pub fn get_access_restrictions(&self, user_uuid: &str, conn: &DbConn) -> Option<(bool, bool)> {
// Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no
// access restrictions.
if self.is_owned_by_user(&user_uuid) || self.is_in_full_access_org(&user_uuid, &conn) {
return Some((false, false));
}
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
let query = 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::atype.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()
.inner_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid))))
.select((users_collections::read_only, users_collections::hide_passwords));
// There's an edge case where a cipher can be in multiple collections
// with inconsistent access flags. For example, a cipher could be in
// one collection where the user has read-only access, but also in
// another collection where the user has read/write access. To handle
// this, we do a boolean OR of all values in each of the `read_only`
// and `hide_passwords` columns. This could ideally be done as part
// of the query, but Diesel doesn't support a max() or bool_or()
// function on booleans and this behavior isn't portable anyway.
if let Some(vec) = query.load::<(bool, bool)>(&**conn).ok() {
let mut read_only = false;
let mut hide_passwords = false;
for (ro, hp) in vec.iter() {
read_only |= ro;
hide_passwords |= hp;
}
Some((read_only, hide_passwords))
} else {
// This cipher isn't in any collections accessible to the user.
None
}
}
pub fn is_write_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
match self.get_access_restrictions(&user_uuid, &conn) {
Some((read_only, _hide_passwords)) => !read_only,
None => false,
}
}
pub fn is_accessible_to_user(&self, user_uuid: &str, conn: &DbConn) -> bool {
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::atype.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()
self.get_access_restrictions(&user_uuid, &conn).is_some()
}
pub fn get_folder_uuid(&self, user_uuid: &str, conn: &DbConn) -> Option<String> {
@@ -340,12 +391,30 @@ impl Cipher {
.load::<Self>(&**conn).expect("Error loading ciphers")
}
pub fn count_owned_by_user(user_uuid: &str, conn: &DbConn) -> i64 {
ciphers::table
.filter(ciphers::user_uuid.eq(user_uuid))
.count()
.first::<i64>(&**conn)
.ok()
.unwrap_or(0)
}
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
ciphers::table
.filter(ciphers::organization_uuid.eq(org_uuid))
.load::<Self>(&**conn).expect("Error loading ciphers")
}
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
ciphers::table
.filter(ciphers::organization_uuid.eq(org_uuid))
.count()
.first::<i64>(&**conn)
.ok()
.unwrap_or(0)
}
pub fn find_by_folder(folder_uuid: &str, conn: &DbConn) -> Vec<Self> {
folders_ciphers::table.inner_join(ciphers::table)
.filter(folders_ciphers::folder_uuid.eq(folder_uuid))

View File

@@ -35,7 +35,6 @@ impl Collection {
use crate::db::schema::*;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;
@@ -200,6 +199,7 @@ pub struct CollectionUser {
pub user_uuid: String,
pub collection_uuid: String,
pub read_only: bool,
pub hide_passwords: bool,
}
/// Database methods
@@ -215,7 +215,7 @@ impl CollectionUser {
}
#[cfg(feature = "postgresql")]
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, conn: &DbConn) -> EmptyResult {
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, hide_passwords: bool, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&user_uuid, conn);
diesel::insert_into(users_collections::table)
@@ -223,16 +223,20 @@ impl CollectionUser {
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
))
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set(users_collections::read_only.eq(read_only))
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
))
.execute(&**conn)
.map_res("Error adding user to collection")
}
#[cfg(not(feature = "postgresql"))]
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, conn: &DbConn) -> EmptyResult {
pub fn save(user_uuid: &str, collection_uuid: &str, read_only: bool, hide_passwords: bool, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&user_uuid, conn);
diesel::replace_into(users_collections::table)
@@ -240,6 +244,7 @@ impl CollectionUser {
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
))
.execute(&**conn)
.map_res("Error adding user to collection")

View File

@@ -5,6 +5,7 @@ use crate::CONFIG;
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "devices"]
#[changeset_options(treat_none_as_null="true")]
#[belongs_to(User, foreign_key = "user_uuid")]
#[primary_key(uuid)]
pub struct Device {
@@ -76,7 +77,6 @@ impl Device {
let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
// Create the JWT claims struct, to send to the client
use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
let claims = LoginJWTClaims {
@@ -107,7 +107,6 @@ impl Device {
use crate::db::schema::devices;
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;

View File

@@ -63,7 +63,6 @@ impl FolderCipher {
use crate::db::schema::{folders, folders_ciphers};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;

View File

@@ -7,6 +7,7 @@ mod user;
mod collection;
mod organization;
mod two_factor;
mod org_policy;
pub use self::attachment::Attachment;
pub use self::cipher::Cipher;
@@ -17,3 +18,4 @@ pub use self::organization::Organization;
pub use self::organization::{UserOrgStatus, UserOrgType, UserOrganization};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::user::{Invitation, User};
pub use self::org_policy::{OrgPolicy, OrgPolicyType};

141
src/db/models/org_policy.rs Normal file
View File

@@ -0,0 +1,141 @@
use diesel::prelude::*;
use serde_json::Value;
use crate::api::EmptyResult;
use crate::db::schema::org_policies;
use crate::db::DbConn;
use crate::error::MapResult;
use super::Organization;
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
#[table_name = "org_policies"]
#[belongs_to(Organization, foreign_key = "org_uuid")]
#[primary_key(uuid)]
pub struct OrgPolicy {
pub uuid: String,
pub org_uuid: String,
pub atype: i32,
pub enabled: bool,
pub data: String,
}
#[allow(dead_code)]
#[derive(num_derive::FromPrimitive)]
pub enum OrgPolicyType {
TwoFactorAuthentication = 0,
MasterPassword = 1,
PasswordGenerator = 2,
}
/// Local methods
impl OrgPolicy {
pub fn new(org_uuid: String, atype: OrgPolicyType, data: String) -> Self {
Self {
uuid: crate::util::get_uuid(),
org_uuid,
atype: atype as i32,
enabled: false,
data,
}
}
pub fn to_json(&self) -> Value {
let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null);
json!({
"Id": self.uuid,
"OrganizationId": self.org_uuid,
"Type": self.atype,
"Data": data_json,
"Enabled": self.enabled,
"Object": "policy",
})
}
}
/// Database methods
impl OrgPolicy {
#[cfg(feature = "postgresql")]
pub fn save(&self, conn: &DbConn) -> EmptyResult {
// We need to make sure we're not going to violate the unique constraint on org_uuid and atype.
// This happens automatically on other DBMS backends due to replace_into(). PostgreSQL does
// not support multiple constraints on ON CONFLICT clauses.
diesel::delete(
org_policies::table
.filter(org_policies::org_uuid.eq(&self.org_uuid))
.filter(org_policies::atype.eq(&self.atype)),
)
.execute(&**conn)
.map_res("Error deleting org_policy for insert")?;
diesel::insert_into(org_policies::table)
.values(self)
.on_conflict(org_policies::uuid)
.do_update()
.set(self)
.execute(&**conn)
.map_res("Error saving org_policy")
}
#[cfg(not(feature = "postgresql"))]
pub fn save(&self, conn: &DbConn) -> EmptyResult {
diesel::replace_into(org_policies::table)
.values(&*self)
.execute(&**conn)
.map_res("Error saving org_policy")
}
pub fn delete(self, conn: &DbConn) -> EmptyResult {
diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid)))
.execute(&**conn)
.map_res("Error deleting org_policy")
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
org_policies::table
.filter(org_policies::uuid.eq(uuid))
.first::<Self>(&**conn)
.ok()
}
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
org_policies::table
.filter(org_policies::org_uuid.eq(org_uuid))
.load::<Self>(&**conn)
.expect("Error loading org_policy")
}
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
use crate::db::schema::users_organizations;
org_policies::table
.left_join(
users_organizations::table.on(
users_organizations::org_uuid.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid)))
)
.select(org_policies::all_columns)
.load::<Self>(&**conn)
.expect("Error loading org_policy")
}
pub fn find_by_org_and_type(org_uuid: &str, atype: i32, conn: &DbConn) -> Option<Self> {
org_policies::table
.filter(org_policies::org_uuid.eq(org_uuid))
.filter(org_policies::atype.eq(atype))
.first::<Self>(&**conn)
.ok()
}
pub fn delete_all_by_organization(org_uuid: &str, conn: &DbConn) -> EmptyResult {
diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid)))
.execute(&**conn)
.map_res("Error deleting org_policy")
}
/*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,7 +1,8 @@
use serde_json::Value;
use std::cmp::Ordering;
use num_traits::FromPrimitive;
use super::{CollectionUser, User};
use super::{CollectionUser, User, OrgPolicy};
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
#[table_name = "organizations"]
@@ -33,6 +34,7 @@ pub enum UserOrgStatus {
}
#[derive(Copy, Clone, PartialEq, Eq)]
#[derive(num_derive::FromPrimitive)]
pub enum UserOrgType {
Owner = 0,
Admin = 1,
@@ -135,16 +137,6 @@ impl UserOrgType {
_ => 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,
}
}
}
/// Local methods
@@ -170,11 +162,12 @@ impl Organization {
"UseEvents": false,
"UseGroups": false,
"UseTotp": true,
"UsePolicies": true,
"BusinessName": null,
"BusinessAddress1": null,
"BusinessAddress2": null,
"BusinessAddress3": null,
"BusinessAddress1": null,
"BusinessAddress2": null,
"BusinessAddress3": null,
"BusinessCountry": null,
"BusinessTaxNumber": null,
@@ -205,7 +198,6 @@ impl UserOrganization {
use crate::db::schema::{ciphers_collections, organizations, users_collections, users_organizations};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;
@@ -250,6 +242,7 @@ impl Organization {
Cipher::delete_all_by_organization(&self.uuid, &conn)?;
Collection::delete_all_by_organization(&self.uuid, &conn)?;
UserOrganization::delete_all_by_organization(&self.uuid, &conn)?;
OrgPolicy::delete_all_by_organization(&self.uuid, &conn)?;
diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid)))
.execute(&**conn)
@@ -262,12 +255,16 @@ impl Organization {
.first::<Self>(&**conn)
.ok()
}
pub fn get_all(conn: &DbConn) -> Vec<Self> {
organizations::table.load::<Self>(&**conn).expect("Error loading organizations")
}
}
impl UserOrganization {
pub fn to_json(&self, conn: &DbConn) -> Value {
let org = Organization::find_by_uuid(&self.org_uuid, conn).unwrap();
json!({
"Id": self.org_uuid,
"Name": org.name,
@@ -280,6 +277,9 @@ impl UserOrganization {
"UseEvents": false,
"UseGroups": false,
"UseTotp": true,
"UsePolicies": true,
"UseApi": false,
"SelfHost": true,
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
@@ -310,10 +310,11 @@ impl UserOrganization {
})
}
pub fn to_json_collection_user_details(&self, read_only: bool) -> Value {
pub fn to_json_user_access_restrictions(&self, col_user: &CollectionUser) -> Value {
json!({
"Id": self.uuid,
"ReadOnly": read_only
"ReadOnly": col_user.read_only,
"HidePasswords": col_user.hide_passwords,
})
}
@@ -324,7 +325,11 @@ impl UserOrganization {
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}))
.map(|c| json!({
"Id": c.collection_uuid,
"ReadOnly": c.read_only,
"HidePasswords": c.hide_passwords,
}))
.collect()
};
@@ -388,8 +393,13 @@ impl UserOrganization {
Ok(())
}
pub fn has_status(self, status: UserOrgStatus) -> bool {
self.status == status as i32
}
pub fn has_full_access(self) -> bool {
self.access_all || self.atype >= UserOrgType::Admin
(self.access_all || self.atype >= UserOrgType::Admin) &&
self.has_status(UserOrgStatus::Confirmed)
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
@@ -437,6 +447,15 @@ impl UserOrganization {
.expect("Error loading user organizations")
}
pub fn count_by_org(org_uuid: &str, conn: &DbConn) -> i64 {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.count()
.first::<i64>(&**conn)
.ok()
.unwrap_or(0)
}
pub fn find_by_org_and_type(org_uuid: &str, atype: i32, conn: &DbConn) -> Vec<Self> {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))

View File

@@ -1,4 +1,3 @@
use diesel;
use diesel::prelude::*;
use serde_json::Value;
@@ -23,7 +22,7 @@ pub struct TwoFactor {
}
#[allow(dead_code)]
#[derive(FromPrimitive)]
#[derive(num_derive::FromPrimitive)]
pub enum TwoFactorType {
Authenticator = 0,
Email = 1,
@@ -60,7 +59,7 @@ impl TwoFactor {
})
}
pub fn to_json_list(&self) -> Value {
pub fn to_json_provider(&self) -> Value {
json!({
"Enabled": self.enabled,
"Type": self.atype,

View File

@@ -6,6 +6,7 @@ use crate::CONFIG;
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
#[table_name = "users"]
#[changeset_options(treat_none_as_null="true")]
#[primary_key(uuid)]
pub struct User {
pub uuid: String,
@@ -120,7 +121,6 @@ impl User {
use super::{Cipher, Device, Folder, TwoFactor, UserOrgType, UserOrganization};
use crate::db::schema::{invitations, users};
use crate::db::DbConn;
use diesel;
use diesel::prelude::*;
use crate::api::EmptyResult;
@@ -274,7 +274,7 @@ pub struct Invitation {
}
impl Invitation {
pub fn new(email: String) -> Self {
pub const fn new(email: String) -> Self {
Self { email }
}

View File

@@ -22,6 +22,7 @@ table! {
data -> Text,
favorite -> Bool,
password_history -> Nullable<Text>,
deleted_at -> Nullable<Datetime>,
}
}
@@ -77,6 +78,16 @@ table! {
}
}
table! {
org_policies (uuid) {
uuid -> Varchar,
org_uuid -> Varchar,
atype -> Integer,
enabled -> Bool,
data -> Text,
}
}
table! {
organizations (uuid) {
uuid -> Varchar,
@@ -130,6 +141,7 @@ table! {
user_uuid -> Varchar,
collection_uuid -> Varchar,
read_only -> Bool,
hide_passwords -> Bool,
}
}
@@ -155,6 +167,7 @@ joinable!(devices -> users (user_uuid));
joinable!(folders -> users (user_uuid));
joinable!(folders_ciphers -> ciphers (cipher_uuid));
joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid));
joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid));
@@ -170,6 +183,7 @@ allow_tables_to_appear_in_same_query!(
folders,
folders_ciphers,
invitations,
org_policies,
organizations,
twofactor,
users,

View File

@@ -22,6 +22,7 @@ table! {
data -> Text,
favorite -> Bool,
password_history -> Nullable<Text>,
deleted_at -> Nullable<Timestamp>,
}
}
@@ -77,6 +78,16 @@ table! {
}
}
table! {
org_policies (uuid) {
uuid -> Text,
org_uuid -> Text,
atype -> Integer,
enabled -> Bool,
data -> Text,
}
}
table! {
organizations (uuid) {
uuid -> Text,
@@ -130,6 +141,7 @@ table! {
user_uuid -> Text,
collection_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
}
}
@@ -155,6 +167,7 @@ joinable!(devices -> users (user_uuid));
joinable!(folders -> users (user_uuid));
joinable!(folders_ciphers -> ciphers (cipher_uuid));
joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid));
joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid));
@@ -170,6 +183,7 @@ allow_tables_to_appear_in_same_query!(
folders,
folders_ciphers,
invitations,
org_policies,
organizations,
twofactor,
users,

View File

@@ -22,6 +22,7 @@ table! {
data -> Text,
favorite -> Bool,
password_history -> Nullable<Text>,
deleted_at -> Nullable<Timestamp>,
}
}
@@ -77,6 +78,16 @@ table! {
}
}
table! {
org_policies (uuid) {
uuid -> Text,
org_uuid -> Text,
atype -> Integer,
enabled -> Bool,
data -> Text,
}
}
table! {
organizations (uuid) {
uuid -> Text,
@@ -130,6 +141,7 @@ table! {
user_uuid -> Text,
collection_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
}
}
@@ -155,6 +167,7 @@ joinable!(devices -> users (user_uuid));
joinable!(folders -> users (user_uuid));
joinable!(folders_ciphers -> ciphers (cipher_uuid));
joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid));
joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid));
@@ -170,6 +183,7 @@ allow_tables_to_appear_in_same_query!(
folders,
folders_ciphers,
invitations,
org_policies,
organizations,
twofactor,
users,

View File

@@ -7,7 +7,6 @@ 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 }
@@ -42,13 +41,16 @@ 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;
use lettre::smtp::error::Error as LettreErr;
#[derive(Display, Serialize)]
use lettre::address::AddressError as AddrErr;
use lettre::error::Error as LettreErr;
use lettre::message::mime::FromStrError as FromStrErr;
use lettre::transport::smtp::error::Error as SmtpErr;
#[derive(Serialize)]
pub struct Empty {}
// Error struct
@@ -74,14 +76,11 @@ make_error! {
ReqError(ReqErr): _has_source, _api_error,
RegexError(RegexErr): _has_source, _api_error,
YubiError(YubiErr): _has_source, _api_error,
LetreErr(LettreErr): _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()))
}
LetreError(LettreErr): _has_source, _api_error,
AddressError(AddrErr): _has_source, _api_error,
SmtpError(SmtpErr): _has_source, _api_error,
FromStrError(FromStrErr): _has_source, _api_error,
}
impl std::fmt::Debug for Error {
@@ -118,7 +117,7 @@ impl Error {
self
}
pub fn with_code(mut self, code: u16) -> Self {
pub const fn with_code(mut self, code: u16) -> Self {
self.error_code = code;
self
}
@@ -146,7 +145,7 @@ impl<S> MapResult<S> for Option<S> {
}
}
fn _has_source<T>(e: T) -> Option<T> {
const fn _has_source<T>(e: T) -> Option<T> {
Some(e)
}
fn _no_source<T, S>(_: T) -> Option<S> {

View File

@@ -1,19 +1,23 @@
use lettre::smtp::authentication::Credentials;
use lettre::smtp::authentication::Mechanism as SmtpAuthMechanism;
use lettre::smtp::ConnectionReuseParameters;
use lettre::{
builder::{EmailBuilder, MimeMultipartType, PartBuilder},
ClientSecurity, ClientTlsParameters, SmtpClient, SmtpTransport, Transport,
};
use std::{env, str::FromStr};
use chrono::{DateTime, Local};
use chrono_tz::Tz;
use native_tls::{Protocol, TlsConnector};
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
use quoted_printable::encode_to_str;
use crate::api::EmptyResult;
use crate::auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims};
use crate::error::Error;
use crate::CONFIG;
use chrono::NaiveDateTime;
use lettre::{
message::{header, Mailbox, Message, MultiPart, SinglePart},
transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism},
transport::smtp::extension::ClientId,
Address, SmtpTransport, Tls, TlsParameters, Transport,
};
use crate::{
api::EmptyResult,
auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims},
error::Error,
CONFIG,
};
fn mailer() -> SmtpTransport {
let host = CONFIG.smtp_host().unwrap();
@@ -24,42 +28,45 @@ fn mailer() -> SmtpTransport {
.build()
.unwrap();
let params = ClientTlsParameters::new(host.clone(), tls);
let params = TlsParameters::new(host.clone(), tls);
if CONFIG.smtp_explicit_tls() {
ClientSecurity::Wrapper(params)
Tls::Wrapper(params)
} else {
ClientSecurity::Required(params)
Tls::Required(params)
}
} else {
ClientSecurity::None
Tls::None
};
use std::time::Duration;
let smtp_client = SmtpClient::new((host.as_str(), CONFIG.smtp_port()), client_security).unwrap();
let smtp_client = SmtpTransport::builder(host).port(CONFIG.smtp_port()).tls(client_security);
let smtp_client = match (&CONFIG.smtp_username(), &CONFIG.smtp_password()) {
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user.clone(), pass.clone())),
let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password()) {
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user, pass)),
_ => smtp_client,
};
let smtp_client = match &CONFIG.smtp_auth_mechanism() {
Some(auth_mechanism_json) => {
let auth_mechanism = serde_json::from_str::<SmtpAuthMechanism>(&auth_mechanism_json);
match auth_mechanism {
Ok(auth_mechanism) => smtp_client.authentication_mechanism(auth_mechanism),
let smtp_client = match CONFIG.helo_name() {
Some(helo_name) => smtp_client.hello_name(ClientId::new(helo_name)),
None => smtp_client,
};
let smtp_client = match CONFIG.smtp_auth_mechanism() {
Some(mechanism) => {
let correct_mechanism = format!("\"{}\"", crate::util::upcase_first(mechanism.trim_matches('"')));
// TODO: Allow more than one mechanism
match serde_json::from_str::<SmtpAuthMechanism>(&correct_mechanism) {
Ok(auth_mechanism) => smtp_client.authentication(vec![auth_mechanism]),
_ => panic!("Failure to parse mechanism. Is it proper Json? Eg. `\"Plain\"` not `Plain`"),
}
}
_ => smtp_client,
};
smtp_client
.smtp_utf8(true)
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())))
.connection_reuse(ConnectionReuseParameters::NoReuse)
.transport()
smtp_client.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))).build()
}
fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> {
@@ -85,6 +92,22 @@ fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String
Ok((subject, body))
}
pub fn format_datetime(dt: &DateTime<Local>) -> String {
let fmt = "%A, %B %_d, %Y at %r %Z";
// With a DateTime<Local>, `%Z` formats as the time zone's UTC offset
// (e.g., `+00:00`). If the `TZ` environment variable is set, try to
// format as a time zone abbreviation instead (e.g., `UTC`).
if let Ok(tz) = env::var("TZ") {
if let Ok(tz) = tz.parse::<Tz>() {
return dt.with_timezone(&tz).format(fmt).to_string();
}
}
// Otherwise, fall back to just displaying the UTC offset.
dt.format(fmt).to_string()
}
pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
let template_name = if hint.is_some() {
"email/pw_hint_some"
@@ -94,7 +117,7 @@ pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
@@ -111,7 +134,7 @@ pub fn send_delete_account(address: &str, uuid: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
@@ -128,7 +151,7 @@ pub fn send_verify_email(address: &str, uuid: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_welcome(address: &str) -> EmptyResult {
@@ -139,7 +162,7 @@ pub fn send_welcome(address: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult {
@@ -155,7 +178,7 @@ pub fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_invite(
@@ -187,7 +210,7 @@ pub fn send_invite(
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
@@ -200,7 +223,7 @@ pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str)
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
@@ -212,26 +235,24 @@ pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &str) -> EmptyResult {
pub fn send_new_device_logged_in(address: &str, ip: &str, dt: &DateTime<Local>, device: &str) -> EmptyResult {
use crate::util::upcase_first;
let device = upcase_first(device);
let datetime = dt.format("%A, %B %_d, %Y at %H:%M").to_string();
let (subject, body_html, body_text) = get_text(
"email/new_device_logged_in",
json!({
"url": CONFIG.domain(),
"ip": ip,
"device": device,
"datetime": datetime,
"datetime": format_datetime(dt),
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_token(address: &str, token: &str) -> EmptyResult {
@@ -243,7 +264,7 @@ pub fn send_token(address: &str, token: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_change_email(address: &str, token: &str) -> EmptyResult {
@@ -255,7 +276,7 @@ pub fn send_change_email(address: &str, token: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
pub fn send_test(address: &str) -> EmptyResult {
@@ -266,7 +287,7 @@ pub fn send_test(address: &str) -> EmptyResult {
}),
)?;
send_email(&address, &subject, &body_html, &body_text)
send_email(address, &subject, &body_html, &body_text)
}
fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) -> EmptyResult {
@@ -282,38 +303,35 @@ fn send_email(address: &str, subject: &str, body_html: &str, body_text: &str) ->
let address = format!("{}@{}", address_split[1], domain_puny);
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 data = MultiPart::mixed()
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::quoted_printable()
.header(header::ContentType("text/plain; charset=utf-8".parse()?))
.body(body_text),
)
.multipart(
MultiPart::related().singlepart(
SinglePart::quoted_printable()
.header(header::ContentType("text/html; charset=utf-8".parse()?))
.body(body_html),
)
// .singlepart(SinglePart::base64() -- Inline files would go here
),
)
// .singlepart(SinglePart::base64() -- Attachments would go here
;
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().as_str(), CONFIG.smtp_from_name().as_str()))
let email = Message::builder()
.to(Mailbox::new(None, Address::from_str(&address)?))
.from(Mailbox::new(
Some(CONFIG.smtp_from_name()),
Address::from_str(&CONFIG.smtp_from())?,
))
.subject(subject)
.child(alternative.build())
.build()
.map_err(|e| Error::new("Error building email", e.to_string()))?;
.multipart(data)?;
let mut transport = mailer();
let result = transport.send(email);
// Explicitly close the connection, in case of error
transport.close();
result?;
let _ = mailer().send(&email)?;
Ok(())
}

View File

@@ -1,7 +1,7 @@
#![feature(proc_macro_hygiene, vec_remove_item, try_trait, ip)]
#![forbid(unsafe_code)]
#![cfg_attr(feature = "unstable", feature(ip))]
#![recursion_limit = "256"]
#[cfg(feature = "openssl")]
extern crate openssl;
#[macro_use]
extern crate rocket;
@@ -15,16 +15,15 @@ extern crate log;
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate derive_more;
#[macro_use]
extern crate num_derive;
use std::{
fmt, // For panic logging
fs::create_dir_all,
panic,
path::Path,
process::{exit, Command},
str::FromStr,
thread,
};
#[macro_use]
@@ -42,6 +41,16 @@ pub use error::{Error, MapResult};
use structopt::StructOpt;
// Used for catching panics and log them to file instead of stderr
use backtrace::Backtrace;
struct Shim(Backtrace);
impl fmt::Debug for Shim {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
write!(fmt, "\n{:?}", self.0)
}
}
#[derive(Debug, StructOpt)]
#[structopt(name = "bitwarden_rs", about = "A Bitwarden API server written in Rust")]
struct Opt {
@@ -76,7 +85,7 @@ fn main() {
fn parse_args() {
let opt = Opt::from_args();
if opt.version {
if let Some(version) = option_env!("GIT_VERSION") {
if let Some(version) = option_env!("BWRS_VERSION") {
println!("bitwarden_rs {}", version);
} else {
println!("bitwarden_rs (Version info from Git not present)");
@@ -89,14 +98,17 @@ fn launch_info() {
println!("/--------------------------------------------------------------------\\");
println!("| Starting Bitwarden_RS |");
if let Some(version) = option_env!("GIT_VERSION") {
if let Some(version) = option_env!("BWRS_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!("| Send usage/configuration questions or feature requests to: |");
println!("| https://bitwardenrs.discourse.group/ |");
println!("| Report suspected bugs/issues in the software itself at: |");
println!("| https://github.com/dani-garcia/bitwarden_rs/issues/new |");
println!("\\--------------------------------------------------------------------/\n");
}
@@ -142,6 +154,42 @@ fn init_logging(level: log::LevelFilter) -> Result<(), fern::InitError> {
logger.apply()?;
// Catch panics and log them instead of default output to StdErr
panic::set_hook(Box::new(|info| {
let backtrace = Backtrace::new();
let thread = thread::current();
let thread = thread.name().unwrap_or("unnamed");
let msg = match info.payload().downcast_ref::<&'static str>() {
Some(s) => *s,
None => match info.payload().downcast_ref::<String>() {
Some(s) => &**s,
None => "Box<Any>",
},
};
match info.location() {
Some(location) => {
error!(
target: "panic", "thread '{}' panicked at '{}': {}:{}{:?}",
thread,
msg,
location.file(),
location.line(),
Shim(backtrace)
);
}
None => error!(
target: "panic",
"thread '{}' panicked at '{}'{:?}",
thread,
msg,
Shim(backtrace)
),
}
}));
Ok(())
}
@@ -273,19 +321,26 @@ mod migrations {
let connection = crate::db::get_connection().expect("Can't connect to DB");
use std::io::stdout;
// Disable Foreign Key Checks during migration
use diesel::RunQueryDsl;
#[cfg(feature = "postgres")]
diesel::sql_query("SET CONSTRAINTS ALL DEFERRED").execute(&connection).expect("Failed to disable Foreign Key Checks during migrations");
#[cfg(feature = "mysql")]
diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0").execute(&connection).expect("Failed to disable Foreign Key Checks during migrations");
#[cfg(feature = "sqlite")]
diesel::sql_query("PRAGMA defer_foreign_keys = ON").execute(&connection).expect("Failed to disable Foreign Key Checks during migrations");
embedded_migrations::run_with_output(&connection, &mut stdout()).expect("Can't run migrations");
}
}
fn launch_rocket(extra_debug: bool) {
// Create Rocket object, this stores current log level and sets its own
let rocket = rocket::ignite();
let basepath = &CONFIG.domain_path();
// If adding more paths here, consider also adding them to
// crate::utils::LOGGED_ROUTES to make sure they appear in the log
let rocket = rocket
let result = rocket::ignite()
.mount(&[basepath, "/"].concat(), api::web_routes())
.mount(&[basepath, "/api"].concat(), api::core_routes())
.mount(&[basepath, "/admin"].concat(), api::admin_routes())
@@ -296,9 +351,10 @@ fn launch_rocket(extra_debug: bool) {
.manage(api::start_notification_server())
.attach(util::AppHeaders())
.attach(util::CORS())
.attach(util::BetterLogging(extra_debug));
.attach(util::BetterLogging(extra_debug))
.launch();
// Launch and print error if there is one
// The launch will restore the original logging level
error!("Launch error {:#?}", rocket.launch());
error!("Launch error {:#?}", result);
}

View File

@@ -108,7 +108,9 @@
"microsoftonline.com",
"office365.com",
"microsoftstore.com",
"xbox.com"
"xbox.com",
"azure.com",
"windowsazure.com"
],
"Excluded": false
},
@@ -126,8 +128,7 @@
"Type": 12,
"Domains": [
"overture.com",
"yahoo.com",
"flickr.com"
"yahoo.com"
],
"Excluded": false
},
@@ -192,7 +193,6 @@
"amazon.it",
"amazon.com.au",
"amazon.co.nz",
"amazon.co.jp",
"amazon.in"
],
"Excluded": false
@@ -775,5 +775,71 @@
"customercontrolpanel.de"
],
"Excluded": false
},
{
"Type": 76,
"Domains": [
"docusign.com",
"docusign.net"
],
"Excluded": false
},
{
"Type": 77,
"Domains": [
"envato.com",
"themeforest.net",
"codecanyon.net",
"videohive.net",
"audiojungle.net",
"graphicriver.net",
"photodune.net",
"3docean.net"
],
"Excluded": false
},
{
"Type": 78,
"Domains": [
"x10hosting.com",
"x10premium.com"
],
"Excluded": false
},
{
"Type": 79,
"Domains": [
"dnsomatic.com",
"opendns.com",
"umbrella.com"
],
"Excluded": false
},
{
"Type": 80,
"Domains": [
"cagreatamerica.com",
"canadaswonderland.com",
"carowinds.com",
"cedarfair.com",
"cedarpoint.com",
"dorneypark.com",
"kingsdominion.com",
"knotts.com",
"miadventure.com",
"schlitterbahn.com",
"valleyfair.com",
"visitkingsisland.com",
"worldsoffun.com"
],
"Excluded": false
},
{
"Type": 81,
"Domains": [
"ubnt.com",
"ui.com"
],
"Excluded": false
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,127 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="robots" content="noindex,nofollow" />
<title>Bitwarden_rs Admin Panel</title>
<link rel="stylesheet" href="{{urlpath}}/bwrs_static/bootstrap.css" />
<script src="{{urlpath}}/bwrs_static/bootstrap-native-v4.js"></script>
<script src="{{urlpath}}/bwrs_static/md5.js"></script>
<script src="{{urlpath}}/bwrs_static/identicon.js"></script>
<style>
body {
padding-top: 70px;
padding-top: 75px;
}
@media (max-width:768px) {
body {
padding-top: 190px;
}
.container {
max-width: 100%;
}
}
img {
width: 48px;
height: 48px;
}
.navbar img {
height: 24px;
width: auto;
}
</style>
<script src="{{urlpath}}/bwrs_static/md5.js"></script>
<script src="{{urlpath}}/bwrs_static/identicon.js"></script>
<script>
function reload() { window.location.reload(); }
function msg(text, reload_page = true) {
text && alert(text);
reload_page && 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) {
const elem = document.getElementById(input_id);
const type = elem.getAttribute("type");
if (type === "text") {
elem.setAttribute("type", "password");
} else {
elem.setAttribute("type", "text");
}
return false;
}
function _post(url, successMsg, errMsg, body, reload_page = true) {
fetch(url, {
method: 'POST',
body: body,
mode: "same-origin",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
}).then( resp => {
if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); }
respStatus = resp.status;
respStatusText = resp.statusText;
return resp.text();
}).then( respText => {
try {
const respJson = JSON.parse(respText);
return respJson ? respJson.ErrorModel.Message : "Unknown error";
} catch (e) {
return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true});
}
}).then( apiMsg => {
msg(errMsg + "\n" + apiMsg, reload_page);
}).catch( e => {
if (e.error === false) { return true; }
else { msg(errMsg + "\n" + e.body, reload_page); }
});
}
</script>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-sm 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="{{urlpath}}/admin">Admin Panel</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/">Vault</a>
</li>
</ul>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top">
<div class="container">
<a class="navbar-brand" href="{{urlpath}}/admin"><img class="pr-1" src="{{urlpath}}/bwrs_static/shield-white.png">Bitwarden_rs Admin</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav mr-auto">
{{#if logged_in}}
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin">Settings</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/users/overview">Users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/organizations/overview">Organizations</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a>
</li>
{{/if}}
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/">Vault</a>
</li>
</ul>
{{#if logged_in}}
<a class="btn btn-sm btn-secondary" href="{{urlpath}}/admin/logout">Log Out</a>
{{/if}}
</div>
</div>
<ul class="navbar-nav">
{{#if version}}
<li class="nav-item">
<span class="navbar-text mr-2">Version: {{version}}</span>
</li>
{{/if}}
{{#if logged_in}}
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/logout">Log Out</a>
</li>
{{/if}}
</ul>
</nav>
{{> (page_content) }}
</body>
</html>
<!-- This script needs to be at the bottom, else it will fail! -->
<script>
// get current URL path and assign 'active' class to the correct nav-item
(function () {
var pathname = window.location.pathname;
if (pathname === "") return;
var navItem = document.querySelectorAll('.navbar-nav .nav-item a[href="'+pathname+'"]');
if (navItem.length === 1) {
navItem[0].parentElement.className = navItem[0].parentElement.className + ' active';
}
})();
</script>
<!-- This script needs to be at the bottom, else it will fail! -->
<script src="{{urlpath}}/bwrs_static/bootstrap-native-v4.js"></script>
</body>
</html>

View File

@@ -0,0 +1,150 @@
<main class="container">
<div id="diagnostics-block" class="my-3 p-3 bg-white rounded shadow">
<h6 class="border-bottom pb-2 mb-2">Diagnostics</h6>
<h3>Version</h3>
<div class="row">
<div class="col-md">
<dl class="row">
<dt class="col-sm-5">Server Installed
<span class="badge badge-success d-none" id="server-success" title="Latest version is installed.">Ok</span>
<span class="badge badge-warning d-none" id="server-warning" title="There seems to be an update available.">Update</span>
<span class="badge badge-info d-none" id="server-branch" title="This is a branched version.">Branched</span>
</dt>
<dd class="col-sm-7">
<span id="server-installed">{{version}}</span>
</dd>
<dt class="col-sm-5">Server Latest
<span class="badge badge-danger d-none" id="server-failed" title="Unable to determine latest version.">Unknown</span>
</dt>
<dd class="col-sm-7">
<span id="server-latest">{{diagnostics.latest_release}}<span id="server-latest-commit" class="d-none">-{{diagnostics.latest_commit}}</span></span>
</dd>
<dt class="col-sm-5">Web Installed
<span class="badge badge-success d-none" id="web-success" title="Latest version is installed.">Ok</span>
<span class="badge badge-warning d-none" id="web-warning" title="There seems to be an update available.">Update</span>
</dt>
<dd class="col-sm-7">
<span id="web-installed">{{diagnostics.web_vault_version}}</span>
</dd>
<dt class="col-sm-5">Web Latest
<span class="badge badge-danger d-none" id="web-failed" title="Unable to determine latest version.">Unknown</span>
</dt>
<dd class="col-sm-7">
<span id="web-latest">{{diagnostics.latest_web_build}}</span>
</dd>
</dl>
</div>
</div>
<h3>Checks</h3>
<div class="row">
<div class="col-md">
<dl class="row">
<dt class="col-sm-5">DNS (github.com)
<span class="badge badge-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span>
<span class="badge badge-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
</dt>
<dd class="col-sm-7">
<span id="dns-resolved">{{diagnostics.dns_resolved}}</span>
</dd>
<dt class="col-sm-5">Date & Time (UTC)
<span class="badge badge-success d-none" id="time-success" title="Time offsets seem to be correct.">Ok</span>
<span class="badge badge-danger d-none" id="time-warning" title="Time offsets are too mouch at drift.">Error</span>
</dt>
<dd class="col-sm-7">
<span id="time-server" class="d-block"><b>Server:</b> <span id="time-server-string">{{diagnostics.server_time}}</span></span>
<span id="time-browser" class="d-block"><b>Browser:</b> <span id="time-browser-string"></span></span>
</dd>
</dl>
</div>
</div>
</div>
</main>
<script>
(() => {
const d = new Date();
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth()+1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
const hour = String(d.getUTCHours()).padStart(2, '0');
const minute = String(d.getUTCMinutes()).padStart(2, '0');
const seconds = String(d.getUTCSeconds()).padStart(2, '0');
const browserUTC = year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + seconds;
document.getElementById("time-browser-string").innerText = browserUTC;
const serverUTC = document.getElementById("time-server-string").innerText;
const timeDrift = (Date.parse(serverUTC) - Date.parse(browserUTC)) / 1000;
if (timeDrift > 30 || timeDrift < -30) {
document.getElementById('time-warning').classList.remove('d-none');
} else {
document.getElementById('time-success').classList.remove('d-none');
}
// Check if the output is a valid IP
const isValidIp = value => (/^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/.test(value) ? true : false);
if (isValidIp(document.getElementById('dns-resolved').innerText)) {
document.getElementById('dns-success').classList.remove('d-none');
} else {
document.getElementById('dns-warning').classList.remove('d-none');
}
let serverInstalled = document.getElementById('server-installed').innerText;
let serverLatest = document.getElementById('server-latest').innerText;
let serverLatestCommit = document.getElementById('server-latest-commit').innerText.replace('-', '');
if (serverInstalled.indexOf('-') !== -1 && serverLatest !== '-' && serverLatestCommit !== '-') {
document.getElementById('server-latest-commit').classList.remove('d-none');
}
const webInstalled = document.getElementById('web-installed').innerText;
const webLatest = document.getElementById('web-latest').innerText;
checkVersions('server', serverInstalled, serverLatest, serverLatestCommit);
checkVersions('web', webInstalled, webLatest);
function checkVersions(platform, installed, latest, commit=null) {
if (installed === '-' || latest === '-') {
document.getElementById(platform + '-failed').classList.remove('d-none');
return;
}
// Only check basic versions, no commit revisions
if (commit === null || installed.indexOf('-') === -1) {
if (installed !== latest) {
document.getElementById(platform + '-warning').classList.remove('d-none');
} else {
document.getElementById(platform + '-success').classList.remove('d-none');
}
} else {
// Check if this is a branched version.
const branchRegex = /(?:\s)\((.*?)\)/;
const branchMatch = installed.match(branchRegex);
if (branchMatch !== null) {
document.getElementById(platform + '-branch').classList.remove('d-none');
}
// This will remove branch info and check if there is a commit hash
const installedRegex = /(\d+\.\d+\.\d+)-(\w+)/;
const instMatch = installed.match(installedRegex);
// It could be that a new tagged version has the same commit hash.
// In this case the version is the same but only the number is different
if (instMatch !== null) {
if (instMatch[2] === commit) {
// The commit hashes are the same, so latest version is installed
document.getElementById(platform + '-success').classList.remove('d-none');
return;
}
}
if (installed === latest) {
document.getElementById(platform + '-success').classList.remove('d-none');
} else {
document.getElementById(platform + '-warning').classList.remove('d-none');
}
}
}
})();
</script>

View File

@@ -0,0 +1,51 @@
<main class="container">
<div id="organizations-block" class="my-3 p-3 bg-white rounded shadow">
<h6 class="border-bottom pb-2 mb-0">Organizations</h6>
<div class="table-responsive-xl small">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th style="width: 24px;" colspan="2">Organization</th>
<th>Users</th>
<th>Items</th>
<th>Attachments</th>
</tr>
</thead>
<tbody>
{{#each organizations}}
<tr>
<td><img class="rounded identicon" data-src="{{Id}}"></td>
<td>
<strong>{{Name}}</strong>
<span class="mr-2">({{BillingEmail}})</span>
<span class="d-block">
<span class="badge badge-success">{{Id}}</span>
</span>
</td>
<td>
<span class="d-block">{{user_count}}</span>
</td>
<td>
<span class="d-block">{{cipher_count}}</span>
</td>
<td>
<span class="d-block"><strong>Amount:</strong> {{attachment_count}}</span>
{{#if attachment_count}}
<span class="d-block"><strong>Size:</strong> {{attachment_size}}</span>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</main>
<script>
document.querySelectorAll("img.identicon").forEach(function (e, i) {
e.src = identicon(e.dataset.src);
});
</script>

View File

@@ -1,68 +1,4 @@
<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 300px; font-size: 90%; text-align: right; padding-right: 15px">
{{#if TwoFactorEnabled}}
<a class="mr-2" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a>
{{/if}}
<a class="mr-2" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
<a class="mr-2" 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" onsubmit="inviteUser(); return false;">
<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>
@@ -202,90 +138,6 @@
</style>
<script>
function reload() { window.location.reload(); }
function msg(text, reload_page = true) {
text && alert(text);
reload_page && 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) {
const elem = document.getElementById(input_id);
const type = elem.getAttribute("type");
if (type === "text") {
elem.setAttribute("type", "password");
} else {
elem.setAttribute("type", "text");
}
return false;
}
function _post(url, successMsg, errMsg, body, reload_page = true) {
fetch(url, {
method: 'POST',
body: body,
mode: "same-origin",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
}).then( resp => {
if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); }
respStatus = resp.status;
respStatusText = resp.statusText;
return resp.text();
}).then( respText => {
try {
const respJson = JSON.parse(respText);
return respJson ? respJson.ErrorModel.Message : "Unknown error";
} catch (e) {
return Promise.reject({body:respStatus + ' - ' + respStatusText, error: true});
}
}).then( apiMsg => {
msg(errMsg + "\n" + apiMsg, reload_page);
}).catch( e => {
if (e.error === false) { return true; }
else { msg(errMsg + "\n" + e.body, reload_page); }
});
}
function deleteUser(id, mail) {
var input_mail = prompt("To delete user '" + mail + "', please type the email below")
if (input_mail != null) {
if (input_mail == mail) {
_post("{{urlpath}}/admin/users/" + id + "/delete",
"User deleted correctly",
"Error deleting user");
} else {
alert("Wrong email, please try again")
}
}
return false;
}
function remove2fa(id) {
_post("{{urlpath}}/admin/users/" + id + "/remove-2fa",
"2FA removed correctly",
"Error removing 2FA");
return false;
}
function deauthUser(id) {
_post("{{urlpath}}/admin/users/" + id + "/deauth",
"Sessions deauthorized correctly",
"Error deauthorizing sessions");
return false;
}
function updateRevisions() {
_post("{{urlpath}}/admin/users/update_revision",
"Success, clients will sync next time they connect",
"Error forcing clients to sync");
return false;
}
function inviteUser() {
inv = document.getElementById("email-invite");
data = JSON.stringify({ "email": inv.value });
inv.value = "";
_post("{{urlpath}}/admin/invite/", "User invited correctly",
"Error inviting user", data);
return false;
}
function smtpTest() {
test_email = document.getElementById("smtp-test-email");
data = JSON.stringify({ "email": test_email.value });
@@ -348,23 +200,6 @@
onChange(); // Trigger the event initially
checkbox.addEventListener("change", onChange);
}
let OrgTypes = {
"0": { "name": "Owner", "color": "orange" },
"1": { "name": "Admin", "color": "blueviolet" },
"2": { "name": "User", "color": "blue" },
"3": { "name": "Manager", "color": "green" },
};
document.querySelectorAll("img.identicon").forEach(function (e, i) {
e.src = identicon(e.dataset.src);
});
document.querySelectorAll("[data-orgtype]").forEach(function (e, i) {
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}}

View File

@@ -0,0 +1,143 @@
<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 class="table-responsive-xl small">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th style="width: 24px;">User</th>
<th></th>
<th style="width:60px; min-width: 60px;">Items</th>
<th>Attachments</th>
<th style="min-width: 140px;">Organizations</th>
<th style="width: 140px; min-width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
{{#each users}}
<tr>
<td><img class="mr-2 rounded identicon" data-src="{{Email}}"></td>
<td>
<strong>{{Name}}</strong>
<span class="d-block">{{Email}}</span>
<span class="d-block">
{{#if TwoFactorEnabled}}
<span class="badge badge-success mr-2" title="2FA is enabled">2FA</span>
{{/if}}
{{#case _Status 1}}
<span class="badge badge-warning mr-2" title="User is invited">Invited</span>
{{/case}}
{{#if EmailVerified}}
<span class="badge badge-success mr-2" title="Email has been verified">Verified</span>
{{/if}}
</span>
</td>
<td>
<span class="d-block">{{cipher_count}}</span>
</td>
<td>
<span class="d-block"><strong>Amount:</strong> {{attachment_count}}</span>
{{#if attachment_count}}
<span class="d-block"><strong>Size:</strong> {{attachment_size}}</span>
{{/if}}
</td>
<td>
{{#each Organizations}}
<span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
{{/each}}
</td>
<td style="font-size: 90%; text-align: right; padding-right: 15px">
{{#if TwoFactorEnabled}}
<a class="d-block" href="#" onclick='remove2fa({{jsesc Id}})'>Remove all 2FA</a>
{{/if}}
<a class="d-block" href="#" onclick='deauthUser({{jsesc Id}})'>Deauthorize sessions</a>
<a class="d-block" href="#" onclick='deleteUser({{jsesc Id}}, {{jsesc Email}})'>Delete User</a>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<div class="mt-3">
<button type="button" class="btn btn-sm btn-danger" 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" onsubmit="inviteUser(); return false;">
<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>
</main>
<script>
function deleteUser(id, mail) {
var input_mail = prompt("To delete user '" + mail + "', please type the email below")
if (input_mail != null) {
if (input_mail == mail) {
_post("{{urlpath}}/admin/users/" + id + "/delete",
"User deleted correctly",
"Error deleting user");
} else {
alert("Wrong email, please try again")
}
}
return false;
}
function remove2fa(id) {
_post("{{urlpath}}/admin/users/" + id + "/remove-2fa",
"2FA removed correctly",
"Error removing 2FA");
return false;
}
function deauthUser(id) {
_post("{{urlpath}}/admin/users/" + id + "/deauth",
"Sessions deauthorized correctly",
"Error deauthorizing sessions");
return false;
}
function updateRevisions() {
_post("{{urlpath}}/admin/users/update_revision",
"Success, clients will sync next time they connect",
"Error forcing clients to sync");
return false;
}
function inviteUser() {
inv = document.getElementById("email-invite");
data = JSON.stringify({ "email": inv.value });
inv.value = "";
_post("{{urlpath}}/admin/invite/", "User invited correctly",
"Error inviting user", data);
return false;
}
let OrgTypes = {
"0": { "name": "Owner", "color": "orange" },
"1": { "name": "Admin", "color": "blueviolet" },
"2": { "name": "User", "color": "blue" },
"3": { "name": "Manager", "color": "green" },
};
document.querySelectorAll("img.identicon").forEach(function (e, i) {
e.src = identicon(e.dataset.src);
});
document.querySelectorAll("[data-orgtype]").forEach(function (e, i) {
let orgtype = OrgTypes[e.dataset.orgtype];
e.style.backgroundColor = orgtype.color;
e.title = orgtype.name;
});
</script>

View File

@@ -87,7 +87,7 @@ Your Email Change
</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;">
<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: max-content;">
<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">

View File

@@ -87,7 +87,7 @@ Delete Your Account
</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;">
<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: max-content;">
<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">

View File

@@ -1,4 +1,4 @@
Invitation accepted
Invitation to {{{org_name}}} accepted
<!---------------->
<html>
<p>

View File

@@ -1,4 +1,4 @@
Invitation accepted
Invitation to {{{org_name}}} 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>
@@ -87,7 +87,7 @@ Invitation accepted
</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;">
<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: max-content;">
<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">

View File

@@ -1,4 +1,4 @@
Invitation to {{org_name}} confirmed
Invitation to {{{org_name}}} confirmed
<!---------------->
<html>
<p>

View File

@@ -1,4 +1,4 @@
Invitation to {{org_name}} confirmed
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>
@@ -87,7 +87,7 @@ Invitation to {{org_name}} confirmed
</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;">
<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: max-content;">
<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">

View File

@@ -1,4 +1,4 @@
New Device Logged In From {{device}}
New Device Logged In From {{{device}}}
<!---------------->
<html>
<p>

View File

@@ -1,4 +1,4 @@
New Device Logged In From {{device}}
New Device Logged In From {{{device}}}
<!---------------->
<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>
@@ -87,7 +87,7 @@ New Device Logged In From {{device}}
</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;">
<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: max-content;">
<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">

View File

@@ -87,7 +87,7 @@ Sorry, you have no password hint...
</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;">
<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: max-content;">
<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">

View File

@@ -87,7 +87,7 @@ Your master password hint
</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;">
<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: max-content;">
<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">

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