Repository: dani-garcia/vaultwarden Branch: main Commit: 2b3736802d5c Files: 513 Total size: 2.7 MB Directory structure: gitextract_kb30epk3/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── config.yml │ └── workflows/ │ ├── build.yml │ ├── check-templates.yml │ ├── hadolint.yml │ ├── release.yml │ ├── releasecache-cleanup.yml │ ├── trivy.yml │ ├── typos.yml │ └── zizmor.yml ├── .gitignore ├── .hadolint.yaml ├── .pre-commit-config.yaml ├── .typos.toml ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── build.rs ├── diesel.toml ├── docker/ │ ├── DockerSettings.yaml │ ├── Dockerfile.alpine │ ├── Dockerfile.debian │ ├── Dockerfile.j2 │ ├── Makefile │ ├── README.md │ ├── bake.sh │ ├── bake_env.sh │ ├── docker-bake.hcl │ ├── healthcheck.sh │ ├── podman-bake.sh │ ├── render_template │ └── start.sh ├── macros/ │ ├── Cargo.toml │ └── src/ │ └── lib.rs ├── migrations/ │ ├── mysql/ │ │ ├── 2018-01-14-171611_create_tables/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-02-17-205753_create_collections_and_orgs/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-04-27-155151_create_users_ciphers/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-05-08-161616_create_collection_cipher_map/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-05-25-232323_update_attachments_reference/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-06-01-112529_update_devices_twofactor_remember/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-07-11-181453_create_u2f_twofactor/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-08-27-172114_update_ciphers/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-09-10-111213_add_invites/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-09-19-144557_add_kdf_columns/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2018-11-27-152651_add_att_key_columns/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-05-26-216651_rename_key_and_type_columns/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-10-10-083032_add_column_to_twofactor/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-11-17-011009_add_email_verification/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-03-13-205045_add_policy_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-04-09-235005_add_cipher_delete_date/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-07-01-214531_add_hide_passwords/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-08-02-025025_add_favorites_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-11-30-224000_add_user_enabled/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-12-09-173101_add_stamp_exception/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-03-11-190243_add_sends/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-04-30-233251_add_reprompt/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-05-11-205202_add_hide_email/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-07-01-203140_add_password_reset_keys/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-08-30-193501_create_emergency_access/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-10-24-164321_add_2fa_incomplete/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-01-17-234911_add_api_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-03-02-210038_update_devices_primary_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-07-27-110000_add_group_support/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-10-18-170602_add_events/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-06-151600_add_reset_password_support/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-11-205851_add_avatar_color/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-31-222222_add_argon2/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-02-18-125735_push_uuid_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-02-200424_create_organization_api_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-17-200424_create_auth_requests_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-28-133700_add_collection_external_id/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-01-170620_update_auth_request_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-02-212336_move_user_external_id/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-10-133000_add_sso/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-14-133000_add_users_organizations_invited_by_email/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-10-21-221242_add_cipher_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-01-12-210182_change_attachment_size/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-14-135828_change_time_stamp_data_type/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-14-170000_add_state_to_sso_nonce/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-26-170000_add_pkce_to_sso_nonce/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-03-06-170000_add_sso_users/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-03-13-170000_sso_users_cascade/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-06-05-131359_add_2fa_duo_store/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-09-04-091351_use_device_type_for_mails/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-01-09-172300_add_manage/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ └── 2025-08-20-120000_sso_nonce_to_auth/ │ │ ├── down.sql │ │ └── up.sql │ ├── postgresql/ │ │ ├── 2019-09-12-100000_create_tables/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-09-16-150000_fix_attachments/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-10-10-083032_add_column_to_twofactor/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2019-11-17-011009_add_email_verification/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-03-13-205045_add_policy_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-04-09-235005_add_cipher_delete_date/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-07-01-214531_add_hide_passwords/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-08-02-025025_add_favorites_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-11-30-224000_add_user_enabled/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2020-12-09-173101_add_stamp_exception/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-03-11-190243_add_sends/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-03-15-163412_rename_send_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-04-30-233251_add_reprompt/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-05-11-205202_add_hide_email/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-07-01-203140_add_password_reset_keys/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-08-30-193501_create_emergency_access/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2021-10-24-164321_add_2fa_incomplete/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-01-17-234911_add_api_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-03-02-210038_update_devices_primary_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-07-27-110000_add_group_support/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2022-10-18-170602_add_events/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-06-151600_add_reset_password_support/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-11-205851_add_avatar_color/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-01-31-222222_add_argon2/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-02-18-125735_push_uuid_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-02-200424_create_organization_api_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-17-200424_create_auth_requests_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-06-28-133700_add_collection_external_id/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-01-170620_update_auth_request_table/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-02-212336_move_user_external_id/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-10-133000_add_sso/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-09-14-133000_add_users_organizations_invited_by_email/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2023-10-21-221242_add_cipher_key/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-01-12-210182_change_attachment_size/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-14-135953_change_time_stamp_data_type/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-14-170000_add_state_to_sso_nonce/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-02-26-170000_add_pkce_to_sso_nonce/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-03-06-170000_add_sso_users/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-03-13-170000_sso_users_cascade/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-06-05-131359_add_2fa_duo_store/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2024-09-04-091351_use_device_type_for_mails/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ ├── 2025-01-09-172300_add_manage/ │ │ │ ├── down.sql │ │ │ └── up.sql │ │ └── 2025-08-20-120000_sso_nonce_to_auth/ │ │ ├── down.sql │ │ └── up.sql │ └── sqlite/ │ ├── 2018-01-14-171611_create_tables/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-02-17-205753_create_collections_and_orgs/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-04-27-155151_create_users_ciphers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-05-08-161616_create_collection_cipher_map/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-05-25-232323_update_attachments_reference/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-06-01-112529_update_devices_twofactor_remember/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-07-11-181453_create_u2f_twofactor/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-08-27-172114_update_ciphers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-09-10-111213_add_invites/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-09-19-144557_add_kdf_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2018-11-27-152651_add_att_key_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-05-26-216651_rename_key_and_type_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-10-10-083032_add_column_to_twofactor/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-11-17-011009_add_email_verification/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-03-13-205045_add_policy_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-04-09-235005_add_cipher_delete_date/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-07-01-214531_add_hide_passwords/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-08-02-025025_add_favorites_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-11-30-224000_add_user_enabled/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-09-173101_add_stamp_exception/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-11-190243_add_sends/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-15-163412_rename_send_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-30-233251_add_reprompt/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-05-11-205202_add_hide_email/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-07-01-203140_add_password_reset_keys/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-08-30-193501_create_emergency_access/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-10-24-164321_add_2fa_incomplete/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-01-17-234911_add_api_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-03-02-210038_update_devices_primary_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-07-27-110000_add_group_support/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-10-18-170602_add_events/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-01-06-151600_add_reset_password_support/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-01-11-205851_add_avatar_color/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-01-31-222222_add_argon2/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-18-125735_push_uuid_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-02-200424_create_organization_api_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-17-200424_create_auth_requests_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-28-133700_add_collection_external_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-01-170620_update_auth_request_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-02-212336_move_user_external_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-10-133000_add_sso/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-14-133000_add_users_organizations_invited_by_email/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-21-221242_add_cipher_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-12-210182_change_attachment_size/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-14-140000_change_time_stamp_data_type/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-14-170000_add_state_to_sso_nonce/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-26-170000_add_pkce_to_sso_nonce/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-03-06-170000_add_sso_users/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-03-13_170000_sso_userscascade/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-06-05-131359_add_2fa_duo_store/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-09-04-091351_use_device_type_for_mails/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-01-09-172300_add_manage/ │ │ ├── down.sql │ │ └── up.sql │ └── 2025-08-20-120000_sso_nonce_to_auth/ │ ├── down.sql │ └── up.sql ├── playwright/ │ ├── .gitignore │ ├── README.md │ ├── compose/ │ │ ├── keycloak/ │ │ │ ├── Dockerfile │ │ │ └── setup.sh │ │ ├── playwright/ │ │ │ └── Dockerfile │ │ └── warden/ │ │ ├── Dockerfile │ │ └── build.sh │ ├── docker-compose.yml │ ├── global-setup.ts │ ├── global-utils.ts │ ├── package.json │ ├── playwright.config.ts │ ├── test.env │ └── tests/ │ ├── collection.spec.ts │ ├── login.smtp.spec.ts │ ├── login.spec.ts │ ├── organization.smtp.spec.ts │ ├── organization.spec.ts │ ├── setups/ │ │ ├── 2fa.ts │ │ ├── db-setup.ts │ │ ├── db-teardown.ts │ │ ├── db-test.ts │ │ ├── orgs.ts │ │ ├── sso-setup.ts │ │ ├── sso-teardown.ts │ │ ├── sso.ts │ │ └── user.ts │ ├── sso_login.smtp.spec.ts │ ├── sso_login.spec.ts │ ├── sso_organization.smtp.spec.ts │ └── sso_organization.spec.ts ├── rust-toolchain.toml ├── rustfmt.toml ├── src/ │ ├── api/ │ │ ├── admin.rs │ │ ├── core/ │ │ │ ├── accounts.rs │ │ │ ├── ciphers.rs │ │ │ ├── emergency_access.rs │ │ │ ├── events.rs │ │ │ ├── folders.rs │ │ │ ├── mod.rs │ │ │ ├── organizations.rs │ │ │ ├── public.rs │ │ │ ├── sends.rs │ │ │ └── two_factor/ │ │ │ ├── authenticator.rs │ │ │ ├── duo.rs │ │ │ ├── duo_oidc.rs │ │ │ ├── email.rs │ │ │ ├── mod.rs │ │ │ ├── protected_actions.rs │ │ │ ├── webauthn.rs │ │ │ └── yubikey.rs │ │ ├── icons.rs │ │ ├── identity.rs │ │ ├── mod.rs │ │ ├── notifications.rs │ │ ├── push.rs │ │ └── web.rs │ ├── auth.rs │ ├── config.rs │ ├── crypto.rs │ ├── db/ │ │ ├── mod.rs │ │ ├── models/ │ │ │ ├── attachment.rs │ │ │ ├── auth_request.rs │ │ │ ├── cipher.rs │ │ │ ├── collection.rs │ │ │ ├── device.rs │ │ │ ├── emergency_access.rs │ │ │ ├── event.rs │ │ │ ├── favorite.rs │ │ │ ├── folder.rs │ │ │ ├── group.rs │ │ │ ├── mod.rs │ │ │ ├── org_policy.rs │ │ │ ├── organization.rs │ │ │ ├── send.rs │ │ │ ├── sso_auth.rs │ │ │ ├── two_factor.rs │ │ │ ├── two_factor_duo_context.rs │ │ │ ├── two_factor_incomplete.rs │ │ │ └── user.rs │ │ ├── query_logger.rs │ │ └── schema.rs │ ├── error.rs │ ├── http_client.rs │ ├── mail.rs │ ├── main.rs │ ├── ratelimit.rs │ ├── sso.rs │ ├── sso_client.rs │ ├── static/ │ │ ├── global_domains.json │ │ ├── scripts/ │ │ │ ├── 404.css │ │ │ ├── admin.css │ │ │ ├── admin.js │ │ │ ├── admin_diagnostics.js │ │ │ ├── admin_organizations.js │ │ │ ├── admin_settings.js │ │ │ ├── admin_users.js │ │ │ ├── bootstrap.bundle.js │ │ │ ├── bootstrap.css │ │ │ ├── datatables.css │ │ │ ├── datatables.js │ │ │ ├── jdenticon-3.3.0.js │ │ │ └── jquery-4.0.0.slim.js │ │ └── templates/ │ │ ├── 404.hbs │ │ ├── admin/ │ │ │ ├── base.hbs │ │ │ ├── diagnostics.hbs │ │ │ ├── login.hbs │ │ │ ├── organizations.hbs │ │ │ ├── settings.hbs │ │ │ └── users.hbs │ │ ├── email/ │ │ │ ├── admin_reset_password.hbs │ │ │ ├── admin_reset_password.html.hbs │ │ │ ├── change_email.hbs │ │ │ ├── change_email.html.hbs │ │ │ ├── change_email_existing.hbs │ │ │ ├── change_email_existing.html.hbs │ │ │ ├── change_email_invited.hbs │ │ │ ├── change_email_invited.html.hbs │ │ │ ├── delete_account.hbs │ │ │ ├── delete_account.html.hbs │ │ │ ├── email_footer.hbs │ │ │ ├── email_footer_text.hbs │ │ │ ├── email_header.hbs │ │ │ ├── emergency_access_invite_accepted.hbs │ │ │ ├── emergency_access_invite_accepted.html.hbs │ │ │ ├── emergency_access_invite_confirmed.hbs │ │ │ ├── emergency_access_invite_confirmed.html.hbs │ │ │ ├── emergency_access_recovery_approved.hbs │ │ │ ├── emergency_access_recovery_approved.html.hbs │ │ │ ├── emergency_access_recovery_initiated.hbs │ │ │ ├── emergency_access_recovery_initiated.html.hbs │ │ │ ├── emergency_access_recovery_rejected.hbs │ │ │ ├── emergency_access_recovery_rejected.html.hbs │ │ │ ├── emergency_access_recovery_reminder.hbs │ │ │ ├── emergency_access_recovery_reminder.html.hbs │ │ │ ├── emergency_access_recovery_timed_out.hbs │ │ │ ├── emergency_access_recovery_timed_out.html.hbs │ │ │ ├── incomplete_2fa_login.hbs │ │ │ ├── incomplete_2fa_login.html.hbs │ │ │ ├── invite_accepted.hbs │ │ │ ├── invite_accepted.html.hbs │ │ │ ├── invite_confirmed.hbs │ │ │ ├── invite_confirmed.html.hbs │ │ │ ├── new_device_logged_in.hbs │ │ │ ├── new_device_logged_in.html.hbs │ │ │ ├── protected_action.hbs │ │ │ ├── protected_action.html.hbs │ │ │ ├── pw_hint_none.hbs │ │ │ ├── pw_hint_none.html.hbs │ │ │ ├── pw_hint_some.hbs │ │ │ ├── pw_hint_some.html.hbs │ │ │ ├── register_verify_email.hbs │ │ │ ├── register_verify_email.html.hbs │ │ │ ├── send_2fa_removed_from_org.hbs │ │ │ ├── send_2fa_removed_from_org.html.hbs │ │ │ ├── send_emergency_access_invite.hbs │ │ │ ├── send_emergency_access_invite.html.hbs │ │ │ ├── send_org_invite.hbs │ │ │ ├── send_org_invite.html.hbs │ │ │ ├── send_single_org_removed_from_org.hbs │ │ │ ├── send_single_org_removed_from_org.html.hbs │ │ │ ├── smtp_test.hbs │ │ │ ├── smtp_test.html.hbs │ │ │ ├── sso_change_email.hbs │ │ │ ├── sso_change_email.html.hbs │ │ │ ├── twofactor_email.hbs │ │ │ ├── twofactor_email.html.hbs │ │ │ ├── verify_email.hbs │ │ │ ├── verify_email.html.hbs │ │ │ ├── welcome.hbs │ │ │ ├── welcome.html.hbs │ │ │ ├── welcome_must_verify.hbs │ │ │ └── welcome_must_verify.html.hbs │ │ └── scss/ │ │ ├── user.vaultwarden.scss.hbs │ │ └── vaultwarden.scss.hbs │ └── util.rs └── tools/ └── global_domains.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ // Ignore everything * // Allow what is needed !.git !docker/healthcheck.sh !docker/start.sh !macros !migrations !src !build.rs !Cargo.lock !Cargo.toml !rustfmt.toml !rust-toolchain.toml ================================================ FILE: .editorconfig ================================================ # EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*] end_of_line = lf charset = utf-8 [*.{rs,py}] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true [*.{yml,yaml}] indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true [Makefile] indent_style = tab ================================================ FILE: .gitattributes ================================================ # Ignore vendored scripts in GitHub stats src/static/scripts/* linguist-vendored ================================================ FILE: .github/CODEOWNERS ================================================ /.github @dani-garcia @BlackDex /.github/** @dani-garcia @BlackDex /.github/CODEOWNERS @dani-garcia @BlackDex /.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex /.github/workflows/** @dani-garcia @BlackDex /SECURITY.md @dani-garcia @BlackDex ================================================ FILE: .github/FUNDING.yml ================================================ github: dani-garcia liberapay: dani-garcia custom: ["https://paypal.me/DaniGG"] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report labels: ["bug"] body: # - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! Please **do not** submit feature requests or ask for help on how to configure Vaultwarden here! The [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions/) has sections for Questions and Ideas. Our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki/) has topics on how to configure Vaultwarden. Also, make sure you are running [![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/releases/latest) of Vaultwarden! Be sure to check and validate the Vaultwarden Admin Diagnostics (`/admin/diagnostics`) page for any errors! See here [how to enable the admin page](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page). > [!IMPORTANT] > ## :bangbang: Search for existing **Closed _AND_ Open** [Issues](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue%20) **_AND_** [Discussions](https://github.com/dani-garcia/vaultwarden/discussions?discussions_q=) regarding your topic before posting! :bangbang: # - type: checkboxes id: checklist attributes: label: Prerequisites description: Please confirm you have completed the following before submitting an issue! options: - label: I have searched the existing **Closed _AND_ Open** [Issues](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue%20) **_AND_** [Discussions](https://github.com/dani-garcia/vaultwarden/discussions?discussions_q=) required: true - label: I have searched and read the [documentation](https://github.com/dani-garcia/vaultwarden/wiki/) required: true # - id: support-string type: textarea attributes: label: Vaultwarden Support String description: Output of the **Generate Support String** from the `/admin/diagnostics` page. placeholder: | 1. Go to the Vaultwarden Admin of your instance https://example.domain.tld/admin/diagnostics 2. Click on `Generate Support String` 3. Click on `Copy To Clipboard` 4. Replace this text by pasting it into this textarea without any modifications validations: required: true # - id: version type: input attributes: label: Vaultwarden Build Version description: What version of Vaultwarden are you running? placeholder: ex. v1.34.0 or v1.34.1-53f58b14 validations: required: true # - id: deployment type: dropdown attributes: label: Deployment method description: How did you deploy Vaultwarden? multiple: false options: - Official Container Image - Build from source - OS Package (apt, yum/dnf, pacman, apk, nix, ...) - Manually Extracted from Container Image - Downloaded from GitHub Actions Release Workflow - Other method validations: required: true # - id: deployment-other type: textarea attributes: label: Custom deployment method description: If you deployed Vaultwarden via any other method, please describe how. # - id: reverse-proxy type: input attributes: label: Reverse Proxy description: Are you using a reverse proxy, if so which and what version? placeholder: ex. nginx 1.29.0, caddy 2.10.0, traefik 3.4.4, haproxy 3.2 validations: required: true # - id: os type: dropdown attributes: label: Host/Server Operating System description: On what operating system are you running the Vaultwarden server? multiple: false options: - Linux - NAS/SAN - Cloud - Windows - macOS - Other validations: required: true # - id: os-version type: input attributes: label: Operating System Version description: What version of the operating system(s) are you seeing the problem on? placeholder: ex. Arch Linux, Ubuntu 24.04, Kubernetes, Synology DSM 7.x, Windows 11 # - id: clients type: dropdown attributes: label: Clients description: What client(s) are you seeing the problem on? multiple: true options: - Web Vault - Browser Extension - CLI - Desktop - Android - iOS validations: required: true # - id: client-version type: input attributes: label: Client Version description: What version(s) of the client(s) are you seeing the problem on? placeholder: ex. CLI v2025.7.0, Firefox 140 - v2025.6.1 # - id: reproduce type: textarea attributes: label: Steps To Reproduce description: How can we reproduce the behavior. value: | 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. Click on '...' 5. Etc '...' validations: required: true # - id: expected type: textarea attributes: label: Expected Result description: A clear and concise description of what you expected to happen. validations: required: true # - id: actual type: textarea attributes: label: Actual Result description: A clear and concise description of what is happening. validations: required: true # - id: logs type: textarea attributes: label: Logs description: Provide the logs generated by Vaultwarden during the time this issue occurs. render: text # - id: screenshots type: textarea attributes: label: Screenshots or Videos description: If applicable, add screenshots and/or a short video to help explain your problem. # - id: additional-context type: textarea attributes: label: Additional Context description: Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: GitHub Discussions for Vaultwarden url: https://github.com/dani-garcia/vaultwarden/discussions about: Use the discussions to request features or get help with usage/configuration. - name: Discourse forum for Vaultwarden url: https://vaultwarden.discourse.group/ about: An alternative to the GitHub Discussions, if this is easier for you. ================================================ FILE: .github/workflows/build.yml ================================================ name: Build permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: push: paths: - ".github/workflows/build.yml" - "src/**" - "migrations/**" - "Cargo.*" - "build.rs" - "rust-toolchain.toml" - "rustfmt.toml" - "diesel.toml" - "docker/Dockerfile.j2" - "docker/DockerSettings.yaml" - "macros/**" pull_request: paths: - ".github/workflows/build.yml" - "src/**" - "migrations/**" - "Cargo.*" - "build.rs" - "rust-toolchain.toml" - "rustfmt.toml" - "diesel.toml" - "docker/Dockerfile.j2" - "docker/DockerSettings.yaml" - "macros/**" defaults: run: shell: bash jobs: build: name: Build and Test ${{ matrix.channel }} runs-on: ubuntu-24.04 timeout-minutes: 120 # Make warnings errors, this is to prevent warnings slipping through. # This is done globally to prevent rebuilds when the RUSTFLAGS env variable changes. env: RUSTFLAGS: "-Dwarnings" strategy: fail-fast: false matrix: channel: - "rust-toolchain" # The version defined in rust-toolchain - "msrv" # The supported MSRV steps: # Install dependencies - name: "Install dependencies Ubuntu" run: sudo apt-get update && sudo apt-get install -y --no-install-recommends openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config # End Install dependencies # Checkout the repo - name: "Checkout" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false fetch-depth: 0 # End Checkout the repo # Determine rust-toolchain version - name: Init Variables id: toolchain env: CHANNEL: ${{ matrix.channel }} run: | if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then RUST_TOOLCHAIN="$(grep -m1 -oP 'channel.*"(\K.*?)(?=")' rust-toolchain.toml)" elif [[ "${CHANNEL}" == 'msrv' ]]; then RUST_TOOLCHAIN="$(grep -m1 -oP 'rust-version\s.*"(\K.*?)(?=")' Cargo.toml)" else RUST_TOOLCHAIN="${CHANNEL}" fi echo "RUST_TOOLCHAIN=${RUST_TOOLCHAIN}" | tee -a "${GITHUB_OUTPUT}" # End Determine rust-toolchain version # Only install the clippy and rustfmt components on the default rust-toolchain - name: "Install rust-toolchain version" uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ Feb 13, 2026, 3:46 AM GMT+1 if: ${{ matrix.channel == 'rust-toolchain' }} with: toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" components: clippy, rustfmt # End Uses the rust-toolchain file to determine version # Install the any other channel to be used for which we do not execute clippy and rustfmt - name: "Install MSRV version" uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ Feb 13, 2026, 3:46 AM GMT+1 if: ${{ matrix.channel != 'rust-toolchain' }} with: toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}" # End Install the MSRV channel to be used # Set the current matrix toolchain version as default - name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default" env: RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} run: | # Remove the rust-toolchain.toml rm rust-toolchain.toml # Set the default rustup default "${RUST_TOOLCHAIN}" # Show environment - name: "Show environment" run: | rustc -vV cargo -vV # End Show environment # Enable Rust Caching - name: Rust Caching uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Only update when really needed! Use a .[.] format. prefix-key: "v2025.09-rust" # End Enable Rust Caching # Run cargo tests # First test all features together, afterwards test them separately. - name: "test features: sqlite,mysql,postgresql,enable_mimalloc,s3" id: test_sqlite_mysql_postgresql_mimalloc_s3 if: ${{ !cancelled() }} run: | cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 - name: "test features: sqlite,mysql,postgresql,enable_mimalloc" id: test_sqlite_mysql_postgresql_mimalloc if: ${{ !cancelled() }} run: | cargo test --profile ci --features sqlite,mysql,postgresql,enable_mimalloc - name: "test features: sqlite,mysql,postgresql" id: test_sqlite_mysql_postgresql if: ${{ !cancelled() }} run: | cargo test --profile ci --features sqlite,mysql,postgresql - name: "test features: sqlite" id: test_sqlite if: ${{ !cancelled() }} run: | cargo test --profile ci --features sqlite - name: "test features: mysql" id: test_mysql if: ${{ !cancelled() }} run: | cargo test --profile ci --features mysql - name: "test features: postgresql" id: test_postgresql if: ${{ !cancelled() }} run: | cargo test --profile ci --features postgresql # End Run cargo tests # Run cargo clippy, and fail on warnings - name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc,s3" id: clippy if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} run: | cargo clippy --profile ci --features sqlite,mysql,postgresql,enable_mimalloc,s3 # End Run cargo clippy # Run cargo fmt (Only run on rust-toolchain defined version) - name: "check formatting" id: formatting if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }} run: | cargo fmt --all -- --check # End Run cargo fmt # Check for any previous failures, if there are stop, else continue. # This is useful so all test/clippy/fmt actions are done, and they can all be addressed - name: "Some checks failed" if: ${{ failure() }} env: TEST_DB_M_S3: ${{ steps.test_sqlite_mysql_postgresql_mimalloc_s3.outcome }} TEST_DB_M: ${{ steps.test_sqlite_mysql_postgresql_mimalloc.outcome }} TEST_DB: ${{ steps.test_sqlite_mysql_postgresql.outcome }} TEST_SQLITE: ${{ steps.test_sqlite.outcome }} TEST_MYSQL: ${{ steps.test_mysql.outcome }} TEST_POSTGRESQL: ${{ steps.test_postgresql.outcome }} CLIPPY: ${{ steps.clippy.outcome }} FMT: ${{ steps.formatting.outcome }} run: | echo "### :x: Checks Failed!" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}" echo "|Job|Status|" >> "${GITHUB_STEP_SUMMARY}" echo "|---|------|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql,enable_mimalloc,s3)|${TEST_DB_M_S3}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql,enable_mimalloc)|${TEST_DB_M}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite,mysql,postgresql)|${TEST_DB}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (sqlite)|${TEST_SQLITE}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (mysql)|${TEST_MYSQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|test (postgresql)|${TEST_POSTGRESQL}|" >> "${GITHUB_STEP_SUMMARY}" echo "|clippy (sqlite,mysql,postgresql,enable_mimalloc,s3)|${CLIPPY}|" >> "${GITHUB_STEP_SUMMARY}" echo "|fmt|${FMT}|" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}" echo "Please check the failed jobs and fix where needed." >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}" exit 1 # Check for any previous failures, if there are stop, else continue. # This is useful so all test/clippy/fmt actions are done, and they can all be addressed - name: "All checks passed" if: ${{ success() }} run: | echo "### :tada: Checks Passed!" >> "${GITHUB_STEP_SUMMARY}" echo "" >> "${GITHUB_STEP_SUMMARY}" ================================================ FILE: .github/workflows/check-templates.yml ================================================ name: Check templates permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: [ push, pull_request ] defaults: run: shell: bash jobs: docker-templates: name: Validate docker templates runs-on: ubuntu-24.04 timeout-minutes: 30 steps: # Checkout the repo - name: "Checkout" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo - name: Run make to rebuild templates working-directory: docker run: make - name: Check for unstaged changes working-directory: docker run: git diff --exit-code continue-on-error: false ================================================ FILE: .github/workflows/hadolint.yml ================================================ name: Hadolint permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: [ push, pull_request ] defaults: run: shell: bash jobs: hadolint: name: Validate Dockerfile syntax runs-on: ubuntu-24.04 timeout-minutes: 30 steps: # Start Docker Buildx - name: Setup Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 driver-opts: | network=host # Download hadolint - https://github.com/hadolint/hadolint/releases - name: Download hadolint run: | sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \ sudo chmod +x /usr/local/bin/hadolint env: HADOLINT_VERSION: 2.14.0 # End Download hadolint # Checkout the repo - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo # Test Dockerfiles with hadolint - name: Run hadolint run: hadolint docker/Dockerfile.{debian,alpine} # End Test Dockerfiles with hadolint # Test Dockerfiles with docker build checks - name: Run docker build check run: | echo "Checking docker/Dockerfile.debian" docker build --check . -f docker/Dockerfile.debian echo "Checking docker/Dockerfile.alpine" docker build --check . -f docker/Dockerfile.alpine # End Test Dockerfiles with docker build checks ================================================ FILE: .github/workflows/release.yml ================================================ name: Release permissions: {} concurrency: # Apply concurrency control only on the upstream repo group: ${{ github.repository == 'dani-garcia/vaultwarden' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }} # Don't cancel other runs when creating a tag cancel-in-progress: ${{ github.ref_type == 'branch' }} on: push: branches: - main tags: # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - '[1-2].[0-9]+.[0-9]+' defaults: run: shell: bash env: # The *_REPO variables need to be configured as repository variables # Append `/settings/variables/actions` to your repo url # DOCKERHUB_REPO needs to be 'index.docker.io//' # Check for Docker hub credentials in secrets HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} # GHCR_REPO needs to be 'ghcr.io//' # Check for Github credentials in secrets HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }} # QUAY_REPO needs to be 'quay.io//' # Check for Quay.io credentials in secrets HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }} jobs: docker-build: name: Build Vaultwarden containers if: ${{ github.repository == 'dani-garcia/vaultwarden' }} permissions: packages: write # Needed to upload packages and artifacts contents: read attestations: write # Needed to generate an artifact attestation for a build id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate runs-on: ${{ contains(matrix.arch, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} timeout-minutes: 120 env: SOURCE_COMMIT: ${{ github.sha }} SOURCE_REPOSITORY_URL: "https://github.com/${{ github.repository }}" strategy: matrix: arch: ["amd64", "arm64", "arm/v7", "arm/v6"] base_image: ["debian","alpine"] steps: - name: Initialize QEMU binfmt support uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 with: platforms: "arm64,arm" # Start Docker Buildx - name: Setup Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # https://github.com/moby/buildkit/issues/3969 # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills with: cache-binary: false buildkitd-config-inline: | [worker.oci] max-parallelism = 2 driver-opts: | network=host # Checkout the repo - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # We need fetch-depth of 0 so we also get all the tag metadata with: persist-credentials: false fetch-depth: 0 # Normalize the architecture string for use in paths and cache keys - name: Normalize architecture string env: MATRIX_ARCH: ${{ matrix.arch }} run: | # Replace slashes with nothing to create a safe string for paths/cache keys NORMALIZED_ARCH="${MATRIX_ARCH//\/}" echo "NORMALIZED_ARCH=${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}" # Determine Source Version - name: Determine Source Version run: | # Get the Source Version for this release GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)" if [[ -n "${GIT_EXACT_TAG}" ]]; then echo "SOURCE_VERSION=${GIT_EXACT_TAG}" | tee -a "${GITHUB_ENV}" else GIT_LAST_TAG="$(git describe --tags --abbrev=0)" echo "SOURCE_VERSION=${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" | tee -a "${GITHUB_ENV}" fi # Login to Docker Hub - name: Login to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} - name: Add registry for DockerHub if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} env: DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} run: | echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}" # Login to GitHub Container Registry - name: Login to GitHub Container Registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} - name: Add registry for ghcr.io if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} env: GHCR_REPO: ${{ vars.GHCR_REPO }} run: | echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}" # Login to Quay.io - name: Login to Quay.io uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_TOKEN }} if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} - name: Add registry for Quay.io if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} env: QUAY_REPO: ${{ vars.QUAY_REPO }} run: | echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}" - name: Configure build cache from/to env: GHCR_REPO: ${{ vars.GHCR_REPO }} BASE_IMAGE: ${{ matrix.base_image }} NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }} run: | # # Check if there is a GitHub Container Registry Login and use it for caching if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}" echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}" else echo "BAKE_CACHE_FROM=" echo "BAKE_CACHE_TO=" fi # - name: Generate tags id: tags env: CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" run: | # Convert comma-separated list to newline-separated set commands TAGS=$(echo "${CONTAINER_REGISTRIES}" | tr ',' '\n' | sed "s|.*|*.tags=&|") # Output for use in next step { echo "TAGS<> "$GITHUB_ENV" - name: Bake ${{ matrix.base_image }} containers id: bake_vw uses: docker/bake-action@82490499d2e5613fcead7e128237ef0b0ea210f7 # v7.0.0 env: BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" SOURCE_VERSION: "${{ env.SOURCE_VERSION }}" SOURCE_REPOSITORY_URL: "${{ env.SOURCE_REPOSITORY_URL }}" with: pull: true source: . files: docker/docker-bake.hcl targets: "${{ matrix.base_image }}-multi" set: | *.cache-from=${{ env.BAKE_CACHE_FROM }} *.cache-to=${{ env.BAKE_CACHE_TO }} *.platform=linux/${{ matrix.arch }} ${{ env.TAGS }} *.output=type=local,dest=./output *.output=type=image,push-by-digest=true,name-canonical=true,push=true - name: Extract digest SHA env: BAKE_METADATA: ${{ steps.bake_vw.outputs.metadata }} BASE_IMAGE: ${{ matrix.base_image }} run: | GET_DIGEST_SHA="$(jq -r --arg base "$BASE_IMAGE" '.[$base + "-multi"]."containerimage.digest"' <<< "${BAKE_METADATA}")" echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}" - name: Export digest env: DIGEST_SHA: ${{ env.DIGEST_SHA }} RUNNER_TEMP: ${{ runner.temp }} run: | mkdir -p "${RUNNER_TEMP}"/digests digest="${DIGEST_SHA}" touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 - name: Rename binaries to match target platform env: NORMALIZED_ARCH: ${{ env.NORMALIZED_ARCH }} run: | mv ./output/vaultwarden vaultwarden-"${NORMALIZED_ARCH}" # Upload artifacts to Github Actions and Attest the binaries - name: Attest binaries uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} - name: Upload binaries as artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: vaultwarden-${{ env.NORMALIZED_ARCH }} merge-manifests: name: Merge manifests runs-on: ubuntu-latest needs: docker-build permissions: packages: write # Needed to upload packages and artifacts attestations: write # Needed to generate an artifact attestation for a build id-token: write # Needed to mint the OIDC token necessary to request a Sigstore signing certificate strategy: matrix: base_image: ["debian","alpine"] steps: - name: Download digests uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: path: ${{ runner.temp }}/digests pattern: digests-*-${{ matrix.base_image }} merge-multiple: true # Login to Docker Hub - name: Login to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} - name: Add registry for DockerHub if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} env: DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} run: | echo "CONTAINER_REGISTRIES=${DOCKERHUB_REPO}" | tee -a "${GITHUB_ENV}" # Login to GitHub Container Registry - name: Login to GitHub Container Registry uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} - name: Add registry for ghcr.io if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} env: GHCR_REPO: ${{ vars.GHCR_REPO }} run: | echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${GHCR_REPO}" | tee -a "${GITHUB_ENV}" # Login to Quay.io - name: Login to Quay.io uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_TOKEN }} if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} - name: Add registry for Quay.io if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} env: QUAY_REPO: ${{ vars.QUAY_REPO }} run: | echo "CONTAINER_REGISTRIES=${CONTAINER_REGISTRIES:+${CONTAINER_REGISTRIES},}${QUAY_REPO}" | tee -a "${GITHUB_ENV}" # Determine Base Tags - name: Determine Base Tags env: BASE_IMAGE_TAG: "${{ matrix.base_image != 'debian' && format('-{0}', matrix.base_image) || '' }}" REF_TYPE: ${{ github.ref_type }} run: | # Check which main tag we are going to build determined by ref_type if [[ "${REF_TYPE}" == "tag" ]]; then echo "BASE_TAGS=latest${BASE_IMAGE_TAG},${GITHUB_REF#refs/*/}${BASE_IMAGE_TAG}${BASE_IMAGE_TAG//-/,}" | tee -a "${GITHUB_ENV}" elif [[ "${REF_TYPE}" == "branch" ]]; then echo "BASE_TAGS=testing${BASE_IMAGE_TAG}" | tee -a "${GITHUB_ENV}" fi - name: Create manifest list, push it and extract digest SHA working-directory: ${{ runner.temp }}/digests env: BASE_TAGS: "${{ env.BASE_TAGS }}" CONTAINER_REGISTRIES: "${{ env.CONTAINER_REGISTRIES }}" run: | IFS=',' read -ra IMAGES <<< "${CONTAINER_REGISTRIES}" IFS=',' read -ra TAGS <<< "${BASE_TAGS}" TAG_ARGS=() for img in "${IMAGES[@]}"; do for tag in "${TAGS[@]}"; do TAG_ARGS+=("-t" "${img}:${tag}") done done echo "Creating manifest" if ! OUTPUT=$(docker buildx imagetools create \ "${TAG_ARGS[@]}" \ $(printf "${IMAGES[0]}@sha256:%s " *) 2>&1); then echo "Manifest creation failed" echo "${OUTPUT}" exit 1 fi echo "Manifest created successfully" echo "${OUTPUT}" # Extract digest SHA for subsequent steps GET_DIGEST_SHA="$(echo "${OUTPUT}" | grep -oE 'sha256:[a-f0-9]{64}' | tail -1)" echo "DIGEST_SHA=${GET_DIGEST_SHA}" | tee -a "${GITHUB_ENV}" # Attest container images - name: Attest - docker.io - ${{ matrix.base_image }} if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && env.DIGEST_SHA != ''}} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ vars.DOCKERHUB_REPO }} subject-digest: ${{ env.DIGEST_SHA }} push-to-registry: true - name: Attest - ghcr.io - ${{ matrix.base_image }} if: ${{ env.HAVE_GHCR_LOGIN == 'true' && env.DIGEST_SHA != ''}} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ vars.GHCR_REPO }} subject-digest: ${{ env.DIGEST_SHA }} push-to-registry: true - name: Attest - quay.io - ${{ matrix.base_image }} if: ${{ env.HAVE_QUAY_LOGIN == 'true' && env.DIGEST_SHA != ''}} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ vars.QUAY_REPO }} subject-digest: ${{ env.DIGEST_SHA }} push-to-registry: true ================================================ FILE: .github/workflows/releasecache-cleanup.yml ================================================ name: Cleanup permissions: {} concurrency: group: ${{ github.workflow }} cancel-in-progress: false on: workflow_dispatch: inputs: manual_trigger: description: "Manual trigger buildcache cleanup" required: false default: "" schedule: - cron: '0 1 * * FRI' jobs: releasecache-cleanup: name: Releasecache Cleanup permissions: packages: write # To be able to cleanup old caches runs-on: ubuntu-24.04 continue-on-error: true timeout-minutes: 30 steps: - name: Delete vaultwarden-buildcache containers uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 with: package-name: 'vaultwarden-buildcache' package-type: 'container' min-versions-to-keep: 0 delete-only-untagged-versions: 'false' ================================================ FILE: .github/workflows/trivy.yml ================================================ name: Trivy permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: push: branches: - main tags: - '*' pull_request: branches: - main schedule: - cron: '08 11 * * *' jobs: trivy-scan: # Only run this in the upstream repo and not on forks # When all forks run this at the same time, it is causing `Too Many Requests` issues if: ${{ github.repository == 'dani-garcia/vaultwarden' }} name: Trivy Scan permissions: security-events: write # To write the security report runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2 env: TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1 with: scan-type: repo ignore-unfixed: true format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: sarif_file: 'trivy-results.sarif' ================================================ FILE: .github/workflows/typos.yml ================================================ name: Code Spell Checking permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: [ push, pull_request ] jobs: typos: name: Run typos spell checking runs-on: ubuntu-24.04 timeout-minutes: 30 steps: # Checkout the repo - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # End Checkout the repo # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too - name: Spell Check Repo uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0 ================================================ FILE: .github/workflows/zizmor.yml ================================================ name: Security Analysis with zizmor permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true on: push: branches: ["main"] pull_request: branches: ["**"] jobs: zizmor: name: Run zizmor runs-on: ubuntu-latest permissions: security-events: write # To write the security report steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run zizmor uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 with: # intentionally not scanning the entire repository, # since it contains integration tests. inputs: ./.github/ ================================================ FILE: .gitignore ================================================ # Local build artifacts target # Data folder data # IDE files .vscode .idea *.iml # Environment file .env # Web vault web-vault ================================================ FILE: .hadolint.yaml ================================================ ignored: # To prevent issues and make clear some images only work on linux/amd64, we ignore this - DL3029 # disable explicit version for apt install - DL3008 # disable explicit version for apk install - DL3018 # Ignore shellcheck info message - SC1091 trustedRegistries: - docker.io - ghcr.io - quay.io ================================================ FILE: .pre-commit-config.yaml ================================================ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - id: check-yaml - id: check-json - id: check-toml - id: mixed-line-ending args: ["--fix=no"] - id: end-of-file-fixer exclude: "(.*js$|.*css$)" - id: check-case-conflict - id: check-merge-conflict - id: detect-private-key - id: check-symlinks - id: forbid-submodules - repo: local hooks: - id: fmt name: fmt description: Format files with cargo fmt. entry: cargo fmt language: system always_run: true pass_filenames: false args: ["--", "--check"] - id: cargo-test name: cargo test description: Test the package for errors. entry: cargo test language: system args: ["--features", "sqlite,mysql,postgresql", "--"] types_or: [rust, file] files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) pass_filenames: false - id: cargo-clippy name: cargo clippy description: Lint Rust sources entry: cargo clippy language: system args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"] types_or: [rust, file] files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) pass_filenames: false - id: check-docker-templates name: check docker templates description: Check if the Docker templates are updated language: system entry: sh args: - "-c" - "cd docker && make" # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too - repo: https://github.com/crate-ci/typos rev: 631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0 hooks: - id: typos ================================================ FILE: .typos.toml ================================================ [files] extend-exclude = [ ".git/", "playwright/", "*.js", # Ignore all JavaScript files "!admin*.js", # Except our own JavaScript files ] ignore-hidden = false [default] extend-ignore-re = [ # We use this in place of the reserved type identifier at some places "typ", # In SMTP it's called HELO, so ignore it "(?i)helo_name", "Server name sent during.+HELO", # COSE Is short for CBOR Object Signing and Encryption, ignore these specific items "COSEKey", "COSEAlgorithm", # Ignore this specific string as it's valid "Ensure they are valid OTPs", # This word is misspelled upstream # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 "AuthRequestResponseRecieved", ] ================================================ FILE: Cargo.toml ================================================ [workspace.package] edition = "2021" rust-version = "1.92.0" license = "AGPL-3.0-only" repository = "https://github.com/dani-garcia/vaultwarden" publish = false [workspace] members = ["macros"] [package] name = "vaultwarden" version = "1.0.0" authors = ["Daniel García "] readme = "README.md" build = "build.rs" resolver = "2" repository.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true publish.workspace = true [features] default = [ # "sqlite", # "mysql", # "postgresql", ] # Empty to keep compatibility, prefer to set USE_SYSLOG=true enable_syslog = [] mysql = ["diesel/mysql", "diesel_migrations/mysql"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"] sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] # Enable to use a vendored and statically linked openssl vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc # This can improve performance for Alpine builds enable_mimalloc = ["dep:mimalloc"] s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] # OIDC specific features oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"] # Enable unstable features, requires nightly # Currently only used to enable rusts official ip support unstable = [] [target."cfg(unix)".dependencies] # Logging syslog = "7.0.0" [dependencies] macros = { path = "./macros" } # Logging log = "0.4.29" fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } tracing = { version = "0.1.44", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work # A `dotenv` implementation for Rust dotenvy = { version = "0.15.7", default-features = false } # Numerical libraries num-traits = "0.2.19" num-derive = "0.4.2" bigdecimal = "0.4.10" # Web framework rocket = { version = "0.5.1", features = ["tls", "json"], default-features = false } rocket_ws = { version ="0.1.1" } # WebSockets libraries rmpv = "1.3.1" # MessagePack library # Concurrent HashMap used for WebSocket messaging and favicons dashmap = "6.1.0" # Async futures futures = "0.3.32" tokio = { version = "1.50.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } tokio-util = { version = "0.7.18", features = ["compat"]} # A generic serialization/deserialization framework serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" # A safe, extensible ORM and Query builder # Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility diesel = { version = "2.3.6", features = ["chrono", "r2d2", "numeric"] } diesel_migrations = "2.3.1" derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] } diesel-derive-newtype = "2.1.2" # Bundled/Static SQLite libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true } # Crypto-related libraries rand = "0.10.0" ring = "0.17.14" subtle = "2.6.1" # UUID generation uuid = { version = "1.22.0", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false } chrono-tz = "0.10.4" time = "0.3.47" # Job scheduler job_scheduler_ng = "2.4.0" # Data encoding library Hex/Base32/Base64 data-encoding = "2.10.0" # JWT library jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false } # TOTP library totp-lite = "2.0.1" # Yubico Library yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false } # WebAuthn libraries # danger-allow-state-serialisation is needed to save the state in the db # danger-credential-internals is needed to support U2F to Webauthn migration webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs-proto = "0.5.4" webauthn-rs-core = "0.5.4" # Handling of URL's for WebAuthn and favicons url = "2.5.8" # Email libraries lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false } percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails email_address = "0.2.9" # HTML Template library handlebars = { version = "6.4.0", features = ["dir_source"] } # HTTP client (Used for favicons, version check, DUO and HIBP API) reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} hickory-resolver = "0.25.2" # Favicon extraction libraries html5gum = "0.8.3" regex = { version = "1.12.3", features = ["std", "perf", "unicode-perl"], default-features = false } data-url = "0.3.2" bytes = "1.11.1" svg-hush = "0.9.6" # Cache function results (Used for version check and favicon fetching) cached = { version = "0.56.0", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.1" cookie_store = "0.22.1" # Used by U2F, JWT and PostgreSQL openssl = "0.10.75" # CLI argument parsing pico-args = "0.5.0" # Macro ident concatenation pastey = "0.2.1" governor = "0.10.4" # OIDC for SSO openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] } moka = { version = "0.12.13", features = ["future"] } # Check client versions for specific features. semver = "1.0.27" # Allow overriding the default memory allocator # Mainly used for the musl builds, since the default musl malloc is very slow mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true } which = "8.0.1" # Argon2 library with support for the PHC format argon2 = "0.5.3" # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN rpassword = "7.4.0" # Loading a dynamic CSS Stylesheet grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } # For retrieving AWS credentials, including temporary SSO credentials anyhow = { version = "1.0.102", optional = true } aws-config = { version = "1.8.15", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } aws-credential-types = { version = "1.2.14", optional = true } aws-smithy-runtime-api = { version = "1.11.6", optional = true } http = { version = "1.4.0", optional = true } reqsign = { version = "0.16.5", optional = true } # Strip debuginfo from the release builds # The debug symbols are to provide better panic traces # Also enable fat LTO and use 1 codegen unit for optimizations [profile.release] strip = "debuginfo" lto = "fat" codegen-units = 1 debug = false # Optimize for size [profile.release-micro] inherits = "release" strip = "symbols" opt-level = "z" panic = "abort" # Profile for systems with low resources # It will use less resources during build [profile.release-low] inherits = "release" strip = "symbols" lto = "thin" codegen-units = 16 # Used for profiling and debugging like valgrind or heaptrack # Inherits release to be sure all optimizations have been done [profile.dbg] inherits = "release" strip = "none" split-debuginfo = "off" debug = "full" # A little bit of a speedup for generic building [profile.dev] split-debuginfo = "unpacked" debug = "line-tables-only" # Used for CI builds to improve compile time [profile.ci] inherits = "dev" debug = false debug-assertions = false strip = "symbols" panic = "abort" # Always build argon2 using opt-level 3 # This is a huge speed improvement during testing [profile.dev.package.argon2] opt-level = 3 # Linting config # https://doc.rust-lang.org/rustc/lints/groups.html [workspace.lints.rust] # Forbid unsafe_code = "forbid" non_ascii_idents = "forbid" # Deny deprecated_in_future = "deny" deprecated_safe = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 } keyword_idents = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 } nonstandard_style = { level = "deny", priority = -1 } noop_method_call = "deny" refining_impl_trait = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 } rust_2024_compatibility = { level = "deny", priority = -1 } single_use_lifetimes = "deny" trivial_casts = "deny" trivial_numeric_casts = "deny" unused = { level = "deny", priority = -1 } unused_import_braces = "deny" unused_lifetimes = "deny" unused_qualifications = "deny" variant_size_differences = "deny" # Allow the following lints since these cause issues with Rust v1.84.0 or newer # Building Vaultwarden with Rust v1.85.0 with edition 2024 also works without issues edition_2024_expr_fragment_specifier = "allow" # Once changed to Rust 2024 this should be removed and macro's should be validated again if_let_rescope = "allow" tail_expr_drop_order = "allow" # https://rust-lang.github.io/rust-clippy/stable/index.html [workspace.lints.clippy] # Warn dbg_macro = "warn" todo = "warn" # Ignore/Allow result_large_err = "allow" # Deny branches_sharing_code = "deny" case_sensitive_file_extension_comparisons = "deny" cast_lossless = "deny" clone_on_ref_ptr = "deny" equatable_if_let = "deny" excessive_precision = "deny" filter_map_next = "deny" float_cmp_const = "deny" implicit_clone = "deny" inefficient_to_string = "deny" iter_on_empty_collections = "deny" iter_on_single_items = "deny" linkedlist = "deny" macro_use_imports = "deny" manual_assert = "deny" manual_instant_elapsed = "deny" manual_string_new = "deny" match_wildcard_for_single_variants = "deny" mem_forget = "deny" needless_borrow = "deny" needless_collect = "deny" needless_continue = "deny" needless_lifetimes = "deny" option_option = "deny" redundant_clone = "deny" string_add_assign = "deny" unnecessary_join = "deny" unnecessary_self_imports = "deny" unnested_or_patterns = "deny" unused_async = "deny" unused_self = "deny" useless_let_if_seq = "deny" verbose_file_reads = "deny" zero_sized_map_values = "deny" [lints] workspace = true ================================================ FILE: LICENSE.txt ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ ![Vaultwarden Logo](./resources/vaultwarden-logo-auto.svg) An alternative server implementation of the Bitwarden Client API, written in Rust and compatible with [official Bitwarden clients](https://bitwarden.com/download/) [[disclaimer](#disclaimer)], perfect for self-hosted deployment where running the official resource-heavy service might not be ideal. --- [![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg?style=for-the-badge&logo=vaultwarden&color=005AA4)](https://github.com/dani-garcia/vaultwarden/releases/latest) [![ghcr.io Pulls](https://img.shields.io/badge/dynamic/json?style=for-the-badge&logo=github&logoColor=fff&color=005AA4&url=https%3A%2F%2Fipitio.github.io%2Fbackage%2Fdani-garcia%2Fvaultwarden%2Fvaultwarden.json&query=%24.downloads&label=ghcr.io%20pulls&cacheSeconds=14400)](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden) [![Docker Pulls](https://img.shields.io/docker/pulls/vaultwarden/server.svg?style=for-the-badge&logo=docker&logoColor=fff&color=005AA4&label=docker.io%20pulls)](https://hub.docker.com/r/vaultwarden/server) [![Quay.io](https://img.shields.io/badge/quay.io-download-005AA4?style=for-the-badge&logo=redhat&cacheSeconds=14400)](https://quay.io/repository/vaultwarden/server)
[![Contributors](https://img.shields.io/github/contributors-anon/dani-garcia/vaultwarden.svg?style=flat-square&logo=vaultwarden&color=005AA4)](https://github.com/dani-garcia/vaultwarden/graphs/contributors) [![Forks](https://img.shields.io/github/forks/dani-garcia/vaultwarden.svg?style=flat-square&logo=github&logoColor=fff&color=005AA4)](https://github.com/dani-garcia/vaultwarden/network/members) [![Stars](https://img.shields.io/github/stars/dani-garcia/vaultwarden.svg?style=flat-square&logo=github&logoColor=fff&color=005AA4)](https://github.com/dani-garcia/vaultwarden/stargazers) [![Issues Open](https://img.shields.io/github/issues/dani-garcia/vaultwarden.svg?style=flat-square&logo=github&logoColor=fff&color=005AA4&cacheSeconds=300)](https://github.com/dani-garcia/vaultwarden/issues) [![Issues Closed](https://img.shields.io/github/issues-closed/dani-garcia/vaultwarden.svg?style=flat-square&logo=github&logoColor=fff&color=005AA4&cacheSeconds=300)](https://github.com/dani-garcia/vaultwarden/issues?q=is%3Aissue+is%3Aclosed) [![AGPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/vaultwarden.svg?style=flat-square&logo=vaultwarden&color=944000&cacheSeconds=14400)](https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt)
[![Dependency Status](https://img.shields.io/badge/dynamic/xml?url=https%3A%2F%2Fdeps.rs%2Frepo%2Fgithub%2Fdani-garcia%2Fvaultwarden%2Fstatus.svg&query=%2F*%5Blocal-name()%3D'svg'%5D%2F*%5Blocal-name()%3D'g'%5D%5B2%5D%2F*%5Blocal-name()%3D'text'%5D%5B4%5D&style=flat-square&logo=rust&label=dependencies&color=005AA4)](https://deps.rs/repo/github/dani-garcia/vaultwarden) [![GHA Release](https://img.shields.io/github/actions/workflow/status/dani-garcia/vaultwarden/release.yml?style=flat-square&logo=github&logoColor=fff&label=Release%20Workflow)](https://github.com/dani-garcia/vaultwarden/actions/workflows/release.yml) [![GHA Build](https://img.shields.io/github/actions/workflow/status/dani-garcia/vaultwarden/build.yml?style=flat-square&logo=github&logoColor=fff&label=Build%20Workflow)](https://github.com/dani-garcia/vaultwarden/actions/workflows/build.yml)
[![Matrix Chat](https://img.shields.io/matrix/vaultwarden:matrix.org.svg?style=flat-square&logo=matrix&logoColor=fff&color=953B00&cacheSeconds=14400)](https://matrix.to/#/#vaultwarden:matrix.org) [![GitHub Discussions](https://img.shields.io/github/discussions/dani-garcia/vaultwarden?style=flat-square&logo=github&logoColor=fff&color=953B00&cacheSeconds=300)](https://github.com/dani-garcia/vaultwarden/discussions) [![Discourse Discussions](https://img.shields.io/discourse/topics?server=https%3A%2F%2Fvaultwarden.discourse.group%2F&style=flat-square&logo=discourse&color=953B00)](https://vaultwarden.discourse.group/) > [!IMPORTANT] > **When using this server, please report any bugs or suggestions directly to us (see [Get in touch](#get-in-touch)), regardless of whatever clients you are using (mobile, desktop, browser...). DO NOT use the official Bitwarden support channels.**
## Features A nearly complete implementation of the Bitwarden Client API is provided, including: * [Personal Vault](https://bitwarden.com/help/managing-items/) * [Send](https://bitwarden.com/help/about-send/) * [Attachments](https://bitwarden.com/help/attachments/) * [Website icons](https://bitwarden.com/help/website-icons/) * [Personal API Key](https://bitwarden.com/help/personal-api-key/) * [Organizations](https://bitwarden.com/help/getting-started-organizations/) - [Collections](https://bitwarden.com/help/about-collections/), [Password Sharing](https://bitwarden.com/help/sharing/), [Member Roles](https://bitwarden.com/help/user-types-access-control/), [Groups](https://bitwarden.com/help/about-groups/), [Event Logs](https://bitwarden.com/help/event-logs/), [Admin Password Reset](https://bitwarden.com/help/admin-reset/), [Directory Connector](https://bitwarden.com/help/directory-sync/), [Policies](https://bitwarden.com/help/policies/) * [Multi/Two Factor Authentication](https://bitwarden.com/help/bitwarden-field-guide-two-step-login/) - [Authenticator](https://bitwarden.com/help/setup-two-step-login-authenticator/), [Email](https://bitwarden.com/help/setup-two-step-login-email/), [FIDO2 WebAuthn](https://bitwarden.com/help/setup-two-step-login-fido/), [YubiKey](https://bitwarden.com/help/setup-two-step-login-yubikey/), [Duo](https://bitwarden.com/help/setup-two-step-login-duo/) * [Emergency Access](https://bitwarden.com/help/emergency-access/) * [Vaultwarden Admin Backend](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page) * [Modified Web Vault client](https://github.com/dani-garcia/bw_web_builds) (Bundled within our containers)
## Usage > [!IMPORTANT] > The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). > That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. There are also [community driven packages](https://github.com/dani-garcia/vaultwarden/wiki/Third-party-packages) which can be used, but those might be lagging behind the latest version or might deviate in the way Vaultwarden is configured, as described in our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki). Alternatively, you can also [build Vaultwarden](https://github.com/dani-garcia/vaultwarden/wiki/Building-binary) yourself. While Vaultwarden is based upon the [Rocket web framework](https://rocket.rs) which has built-in support for TLS our recommendation would be that you setup a reverse proxy (see [proxy examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)). > [!TIP] >**For more detailed examples on how to install, use and configure Vaultwarden you can check our [Wiki](https://github.com/dani-garcia/vaultwarden/wiki).** ### Docker/Podman CLI Pull the container image and mount a volume from the host for persistent storage.
You can replace `docker` with `podman` if you prefer to use podman. ```shell docker pull vaultwarden/server:latest docker run --detach --name vaultwarden \ --env DOMAIN="https://vw.domain.tld" \ --volume /vw-data/:/data/ \ --restart unless-stopped \ --publish 127.0.0.1:8000:80 \ vaultwarden/server:latest ``` This will preserve any persistent data under `/vw-data/`, you can adapt the path to whatever suits you. ### Docker Compose To use Docker compose you need to create a `compose.yaml` which will hold the configuration to run the Vaultwarden container. ```yaml services: vaultwarden: image: vaultwarden/server:latest container_name: vaultwarden restart: unless-stopped environment: DOMAIN: "https://vw.domain.tld" volumes: - ./vw-data/:/data/ ports: - 127.0.0.1:8000:80 ```
## Get in touch Have a question, suggestion or need help? Join our community on [Matrix](https://matrix.to/#/#vaultwarden:matrix.org), [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions) or [Discourse Forums](https://vaultwarden.discourse.group/). Encountered a bug or crash? Please search our issue tracker and discussions to see if it's already been reported. If not, please [start a new discussion](https://github.com/dani-garcia/vaultwarden/discussions) or [create a new issue](https://github.com/dani-garcia/vaultwarden/issues/). Ensure you're using the latest version of Vaultwarden and there aren't any similar issues open or closed!
## Contributors Thanks for your contribution to the project! [![Contributors Count](https://img.shields.io/github/contributors-anon/dani-garcia/vaultwarden?style=for-the-badge&logo=vaultwarden&color=005AA4)](https://github.com/dani-garcia/vaultwarden/graphs/contributors)
[![Contributors Avatars](https://contributors-img.web.app/image?repo=dani-garcia/vaultwarden)](https://github.com/dani-garcia/vaultwarden/graphs/contributors)
## Disclaimer **This project is not associated with [Bitwarden](https://bitwarden.com/) or Bitwarden, Inc.** However, one of the active maintainers for Vaultwarden is employed by Bitwarden and is allowed to contribute to the project on their own time. These contributions are independent of Bitwarden and are reviewed by other maintainers. The maintainers work together to set the direction for the project, focusing on serving the self-hosting community, including individuals, families, and small organizations, while ensuring the project's sustainability. **Please note:** We cannot be held liable for any data loss that may occur while using Vaultwarden. This includes passwords, attachments, and other information handled by the application. We highly recommend performing regular backups of your files and database. However, should you experience data loss, we encourage you to contact us immediately.
## Bitwarden_RS This project was known as Bitwarden_RS and has been renamed to separate itself from the official Bitwarden server in the hopes of avoiding confusion and trademark/branding issues.
Please see [#1642 - v1.21.0 release and project rename to Vaultwarden](https://github.com/dani-garcia/vaultwarden/discussions/1642) for more explanation. ================================================ FILE: SECURITY.md ================================================ Vaultwarden tries to prevent security issues but there could always slip something through. If you believe you've found a security issue in our application, we encourage you to notify us. We welcome working with you to resolve the issue promptly. Thanks in advance! # Disclosure Policy - Let us know as soon as possible upon discovery of a potential security issue, and we'll make every effort to quickly resolve the issue. - Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a third-party. We may publicly disclose the issue before resolving it, if appropriate. - Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our service. Only interact with accounts you own or with explicit permission of the account holder. # In-scope - Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release. # Exclusions The following bug classes are out-of scope: - Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues) - Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated - Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer - Attacks requiring physical access to a user's device - Issues related to software or protocols not under Vaultwarden's control - Vulnerabilities in outdated versions of Vaultwarden - Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue) - Issues that do not have any impact on the general public While researching, we'd like to ask you to refrain from: - Denial of service - Spamming - Social engineering (including phishing) of Vaultwarden developers, contributors or users Thank you for helping keep Vaultwarden and our users safe! # How to contact us - You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`) - You can send an ![security-contact](/.github/security-contact.gif) to report a security issue.
If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC
It can be found on several public GPG key servers.
* https://keys.openpgp.org/search?q=security%40vaultwarden.org * https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org * https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index * https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index ================================================ FILE: build.rs ================================================ use std::env; use std::process::Command; fn main() { // This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros #[cfg(feature = "sqlite")] println!("cargo:rustc-cfg=sqlite"); #[cfg(feature = "mysql")] println!("cargo:rustc-cfg=mysql"); #[cfg(feature = "postgresql")] println!("cargo:rustc-cfg=postgresql"); #[cfg(feature = "s3")] println!("cargo:rustc-cfg=s3"); #[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" ); // Use check-cfg to let cargo know which cfg's we define, // and avoid warnings when they are used in the code. println!("cargo::rustc-check-cfg=cfg(sqlite)"); println!("cargo::rustc-check-cfg=cfg(mysql)"); println!("cargo::rustc-check-cfg=cfg(postgresql)"); println!("cargo::rustc-check-cfg=cfg(s3)"); // Rerun when these paths are changed. // Someone could have checked-out a tag or specific commit, but no other files changed. println!("cargo:rerun-if-changed=.git"); println!("cargo:rerun-if-changed=.git/HEAD"); println!("cargo:rerun-if-changed=.git/index"); println!("cargo:rerun-if-changed=.git/refs/tags"); // Support $BWRS_VERSION for legacy compatibility, but default to $VW_VERSION. // If neither exist, read from git. let maybe_vaultwarden_version = env::var("VW_VERSION").or_else(|_| env::var("BWRS_VERSION")).or_else(|_| version_from_git_info()); if let Ok(version) = maybe_vaultwarden_version { println!("cargo:rustc-env=VW_VERSION={version}"); println!("cargo:rustc-env=CARGO_PKG_VERSION={version}"); } } fn run(args: &[&str]) -> Result { let out = Command::new(args[0]).args(&args[1..]).output()?; if !out.status.success() { use std::io::Error; return Err(Error::other("Command not successful")); } Ok(String::from_utf8(out.stdout).unwrap().trim().to_string()) } /// This method reads info from Git, namely tags, branch, and revision /// To access these values, use: /// - `env!("GIT_EXACT_TAG")` /// - `env!("GIT_LAST_TAG")` /// - `env!("GIT_BRANCH")` /// - `env!("GIT_REV")` /// - `env!("VW_VERSION")` fn version_from_git_info() -> Result { // The exact tag for the current commit, can be empty when // the current commit doesn't have an associated tag let exact_tag = run(&["git", "describe", "--abbrev=0", "--tags", "--exact-match"]).ok(); if let Some(ref exact) = exact_tag { println!("cargo:rustc-env=GIT_EXACT_TAG={exact}"); } // The last available tag, equal to exact_tag when // the current commit is tagged let last_tag = run(&["git", "describe", "--abbrev=0", "--tags"])?; println!("cargo:rustc-env=GIT_LAST_TAG={last_tag}"); // The current branch name let branch = run(&["git", "rev-parse", "--abbrev-ref", "HEAD"])?; println!("cargo:rustc-env=GIT_BRANCH={branch}"); // The current git commit hash let rev = run(&["git", "rev-parse", "HEAD"])?; let rev_short = rev.get(..8).unwrap_or_default(); println!("cargo:rustc-env=GIT_REV={rev_short}"); // Combined version if let Some(exact) = exact_tag { Ok(exact) } else if &branch != "main" && &branch != "master" && &branch != "HEAD" { Ok(format!("{last_tag}-{rev_short} ({branch})")) } else { Ok(format!("{last_tag}-{rev_short}")) } } ================================================ FILE: diesel.toml ================================================ # For documentation on how to configure this file, # see diesel.rs/guides/configuring-diesel-cli [print_schema] file = "src/db/schema.rs" ================================================ FILE: docker/DockerSettings.yaml ================================================ --- vault_version: "v2026.2.0" vault_image_digest: "sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447" # Cross Compile Docker Helper Scripts v1.9.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" rust_version: 1.94.0 # Rust version to be used debian_version: trixie # Debian release name to be used alpine_version: "3.23" # Alpine version to be used # For which platforms/architectures will we try to build images platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] # Determine the build images per OS/Arch build_stage_image: debian: image: "docker.io/library/rust:{{rust_version}}-slim-{{debian_version}}" platform: "$BUILDPLATFORM" alpine: image: "build_${TARGETARCH}${TARGETVARIANT}" arch_image: amd64: "ghcr.io/blackdex/rust-musl:x86_64-musl-stable-{{rust_version}}" arm64: "ghcr.io/blackdex/rust-musl:aarch64-musl-stable-{{rust_version}}" armv7: "ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-{{rust_version}}" armv6: "ghcr.io/blackdex/rust-musl:arm-musleabi-stable-{{rust_version}}" # The final image which will be used to distribute the container images runtime_stage_image: debian: "docker.io/library/debian:{{debian_version}}-slim" alpine: "docker.io/library/alpine:{{alpine_version}}" ================================================ FILE: docker/Dockerfile.alpine ================================================ # syntax=docker/dockerfile:1 # check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform # This file was generated using a Jinja2 template. # Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make` # This will generate two Dockerfile's `Dockerfile.debian` and `Dockerfile.alpine` # Using multistage build: # https://docs.docker.com/develop/develop-images/multistage-build/ # https://whitfin.io/speeding-up-rust-docker-builds/ ####################### VAULT BUILD IMAGE ####################### # The web-vault digest specifies a particular web-vault build on Docker Hub. # Using the digest instead of the tag name provides better security, # as the digest of an image is immutable, whereas a tag name can later # be changed to point to a malicious image. # # To verify the current digest for a given tag name: # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: # $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0 # [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447] # # - Conversely, to get the tag name from the digest: # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 # [docker.io/vaultwarden/web-vault:v2026.2.0] # FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## And for Alpine we define all build images here, they will only be loaded when actually used FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.94.0 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.94.0 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.94.0 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.94.0 AS build_armv6 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 FROM --platform=$BUILDPLATFORM build_${TARGETARCH}${TARGETVARIANT} AS build ARG TARGETARCH ARG TARGETVARIANT ARG TARGETPLATFORM SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Build time options to avoid dpkg warnings and help with reproducible builds. ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ TZ=UTC \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" \ # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 # Debian Trixie uses libpq v17 PQ_LIB_DIR="/usr/local/musl/pq17/lib" # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" && \ rustup set profile minimal # Creates a dummy project used to grab dependencies RUN USER=root cargo new --bin /app WORKDIR /app # Environment variables for Cargo on Alpine based builds RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ # Output the current contents of the file cat /env-cargo RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ COPY ./macros ./macros ARG CARGO_PROFILE=release # Configure the DB ARG as late as possible to not invalidate the cached layers above # Enable MiMalloc to improve performance on Alpine builds ARG DB=sqlite,mysql,postgresql,enable_mimalloc # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies RUN source /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete # Copies the complete project # To avoid copying unneeded files, use .dockerignore COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ fi ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image # because we already have a binary built # # To build these images you need to have qemu binfmt support. # See the following pages to help install these tools locally # Ubuntu/Debian: https://wiki.debian.org/QemuUserEmulation # Arch Linux: https://wiki.archlinux.org/title/QEMU#Chrooting_into_arm/arm64_environment_from_x86_64 # # Or use a Docker image which modifies your host system to support this. # The GitHub Actions Workflow uses the same image as used below. # See: https://github.com/tonistiigi/binfmt # Usage: docker run --privileged --rm tonistiigi/binfmt --install arm64,arm # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23 ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ ROCKET_PORT=80 \ SSL_CERT_DIR=/etc/ssl/certs # Create data folder and Install needed libraries RUN mkdir /data && \ apk --no-cache add \ ca-certificates \ curl \ openssl \ tzdata VOLUME /data EXPOSE 80 # Copies the files from the context (Rocket.toml file and web-vault) # and the binary from the "build" stage to the current stage WORKDIR / COPY docker/healthcheck.sh docker/start.sh / COPY --from=vault /web-vault ./web-vault COPY --from=build /app/target/final/vaultwarden . HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] CMD ["/start.sh"] ================================================ FILE: docker/Dockerfile.debian ================================================ # syntax=docker/dockerfile:1 # check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform # This file was generated using a Jinja2 template. # Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make` # This will generate two Dockerfile's `Dockerfile.debian` and `Dockerfile.alpine` # Using multistage build: # https://docs.docker.com/develop/develop-images/multistage-build/ # https://whitfin.io/speeding-up-rust-docker-builds/ ####################### VAULT BUILD IMAGE ####################### # The web-vault digest specifies a particular web-vault build on Docker Hub. # Using the digest instead of the tag name provides better security, # as the digest of an image is immutable, whereas a tag name can later # be changed to point to a malicious image. # # To verify the current digest for a given tag name: # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: # $ docker pull docker.io/vaultwarden/web-vault:v2026.2.0 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.2.0 # [docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447] # # - Conversely, to get the tag name from the digest: # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 # [docker.io/vaultwarden/web-vault:v2026.2.0] # FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:37c8661fa59dcdfbd3baa8366b6e950ef292b15adfeff1f57812b075c1fd3447 AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## And these bash scripts do not have any significant difference if at all FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.94.0-slim-trixie AS build COPY --from=xx / / ARG TARGETARCH ARG TARGETVARIANT ARG TARGETPLATFORM SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Build time options to avoid dpkg warnings and help with reproducible builds. ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ TZ=UTC \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" # Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version # Install the libc cross packages based upon the debian-arch RUN apt-get update && \ apt-get install -y \ --no-install-recommends \ clang \ pkg-config \ git \ "libc6-$(xx-info debian-arch)-cross" \ "libc6-dev-$(xx-info debian-arch)-cross" \ "linux-libc-dev-$(xx-info debian-arch)-cross" && \ xx-apt-get install -y \ --no-install-recommends \ gcc \ libpq-dev \ libpq5 \ libssl-dev \ libmariadb-dev \ zlib1g-dev && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" && \ rustup set profile minimal # Creates a dummy project used to grab dependencies RUN USER=root cargo new --bin /app WORKDIR /app # Environment variables for Cargo on Debian based builds ARG TARGET_PKG_CONFIG_PATH RUN source /env-cargo && \ if xx-info is-cross ; then \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CROSS_COMPILE=1" >> /env-cargo && \ echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ # For some architectures `xx-info` returns a triple which doesn't matches the path on disk # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ else \ echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ fi && \ echo "# End of env-cargo" >> /env-cargo ; \ fi && \ # Output the current contents of the file cat /env-cargo RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ COPY ./macros ./macros ARG CARGO_PROFILE=release # Configure the DB ARG as late as possible to not invalidate the cached layers above ARG DB=sqlite,mysql,postgresql # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies RUN source /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete # Copies the complete project # To avoid copying unneeded files, use .dockerignore COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ fi ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image # because we already have a binary built # # To build these images you need to have qemu binfmt support. # See the following pages to help install these tools locally # Ubuntu/Debian: https://wiki.debian.org/QemuUserEmulation # Arch Linux: https://wiki.archlinux.org/title/QEMU#Chrooting_into_arm/arm64_environment_from_x86_64 # # Or use a Docker image which modifies your host system to support this. # The GitHub Actions Workflow uses the same image as used below. # See: https://github.com/tonistiigi/binfmt # Usage: docker run --privileged --rm tonistiigi/binfmt --install arm64,arm # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ ROCKET_PORT=80 \ DEBIAN_FRONTEND=noninteractive # Create data folder and Install needed libraries RUN mkdir /data && \ apt-get update && apt-get install -y \ --no-install-recommends \ ca-certificates \ curl \ libmariadb3 \ libpq5 \ openssl && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* VOLUME /data EXPOSE 80 # Copies the files from the context (Rocket.toml file and web-vault) # and the binary from the "build" stage to the current stage WORKDIR / COPY docker/healthcheck.sh docker/start.sh / COPY --from=vault /web-vault ./web-vault COPY --from=build /app/target/final/vaultwarden . HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] CMD ["/start.sh"] ================================================ FILE: docker/Dockerfile.j2 ================================================ # syntax=docker/dockerfile:1 # check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform # This file was generated using a Jinja2 template. # Please make your changes in `DockerSettings.yaml` or `Dockerfile.j2` and then `make` # This will generate two Dockerfile's `Dockerfile.debian` and `Dockerfile.alpine` # Using multistage build: # https://docs.docker.com/develop/develop-images/multistage-build/ # https://whitfin.io/speeding-up-rust-docker-builds/ ####################### VAULT BUILD IMAGE ####################### # The web-vault digest specifies a particular web-vault build on Docker Hub. # Using the digest instead of the tag name provides better security, # as the digest of an image is immutable, whereas a tag name can later # be changed to point to a malicious image. # # To verify the current digest for a given tag name: # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: # $ docker pull docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }} # $ docker image inspect --format "{{ '{{' }}.RepoDigests}}" docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }} # [docker.io/vaultwarden/web-vault@{{ vault_image_digest }}] # # - Conversely, to get the tag name from the digest: # $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }} # [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] # FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault {% if base == "debian" %} ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## And these bash scripts do not have any significant difference if at all FROM --platform=linux/amd64 docker.io/tonistiigi/xx@{{ xx_image_digest }} AS xx {% elif base == "alpine" %} ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## And for Alpine we define all build images here, they will only be loaded when actually used {% for arch in build_stage_image[base].arch_image %} FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS build_{{ arch }} {% endfor %} {% endif %} ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build {% if base == "debian" %} COPY --from=xx / / {% endif %} ARG TARGETARCH ARG TARGETVARIANT ARG TARGETPLATFORM SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Build time options to avoid dpkg warnings and help with reproducible builds. ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ TZ=UTC \ TERM=xterm-256color \ CARGO_HOME="/root/.cargo" \ USER="root" {%- if base == "alpine" %} \ # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 # Debian Trixie uses libpq v17 PQ_LIB_DIR="/usr/local/musl/pq17/lib" {% endif %} {% if base == "debian" %} # Install clang to get `xx-cargo` working # Install pkg-config to allow amd64 builds to find all libraries # Install git so build.rs can determine the correct version # Install the libc cross packages based upon the debian-arch RUN apt-get update && \ apt-get install -y \ --no-install-recommends \ clang \ pkg-config \ git \ "libc6-$(xx-info debian-arch)-cross" \ "libc6-dev-$(xx-info debian-arch)-cross" \ "linux-libc-dev-$(xx-info debian-arch)-cross" && \ xx-apt-get install -y \ --no-install-recommends \ gcc \ libpq-dev \ libpq5 \ libssl-dev \ libmariadb-dev \ zlib1g-dev && \ # Run xx-cargo early, since it sometimes seems to break when run at a later stage echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo {% endif %} # Create CARGO_HOME folder and don't download rust docs RUN mkdir -pv "${CARGO_HOME}" && \ rustup set profile minimal # Creates a dummy project used to grab dependencies RUN USER=root cargo new --bin /app WORKDIR /app {% if base == "debian" %} # Environment variables for Cargo on Debian based builds ARG TARGET_PKG_CONFIG_PATH RUN source /env-cargo && \ if xx-info is-cross ; then \ # We can't use xx-cargo since that uses clang, which doesn't work for our libraries. # Because of this we generate the needed environment variables here which we can load in the needed steps. echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \ echo "export CROSS_COMPILE=1" >> /env-cargo && \ echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \ # For some architectures `xx-info` returns a triple which doesn't matches the path on disk # In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \ echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \ else \ echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \ fi && \ echo "# End of env-cargo" >> /env-cargo ; \ fi && \ # Output the current contents of the file cat /env-cargo {% elif base == "alpine" %} # Environment variables for Cargo on Alpine based builds RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ # Output the current contents of the file cat /env-cargo {% endif %} RUN source /env-cargo && \ rustup target add "${CARGO_TARGET}" # Copies over *only* your manifests and build files COPY ./Cargo.* ./rust-toolchain.toml ./build.rs ./ COPY ./macros ./macros ARG CARGO_PROFILE=release # Configure the DB ARG as late as possible to not invalidate the cached layers above {% if base == "debian" %} ARG DB=sqlite,mysql,postgresql {% elif base == "alpine" %} # Enable MiMalloc to improve performance on Alpine builds ARG DB=sqlite,mysql,postgresql,enable_mimalloc {% endif %} # Builds your dependencies and removes the # dummy project, except the target folder # This folder contains the compiled dependencies RUN source /env-cargo && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ find . -not -path "./target*" -delete # Copies the complete project # To avoid copying unneeded files, use .dockerignore COPY . . ARG VW_VERSION # Builds again, this time it will be the actual source files being build RUN source /env-cargo && \ # Make sure that we actually build the project by updating the src/main.rs timestamp # Also do this for build.rs to ensure the version is rechecked touch build.rs src/main.rs && \ # Create a symlink to the binary target folder to easy copy the binary in the final stage cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ else \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ fi ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image # because we already have a binary built # # To build these images you need to have qemu binfmt support. # See the following pages to help install these tools locally # Ubuntu/Debian: https://wiki.debian.org/QemuUserEmulation # Arch Linux: https://wiki.archlinux.org/title/QEMU#Chrooting_into_arm/arm64_environment_from_x86_64 # # Or use a Docker image which modifies your host system to support this. # The GitHub Actions Workflow uses the same image as used below. # See: https://github.com/tonistiigi/binfmt # Usage: docker run --privileged --rm tonistiigi/binfmt --install arm64,arm # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }} ENV ROCKET_PROFILE="release" \ ROCKET_ADDRESS=0.0.0.0 \ ROCKET_PORT=80 {%- if base == "debian" %} \ DEBIAN_FRONTEND=noninteractive {% elif base == "alpine" %} \ SSL_CERT_DIR=/etc/ssl/certs {% endif %} # Create data folder and Install needed libraries RUN mkdir /data && \ {% if base == "debian" %} apt-get update && apt-get install -y \ --no-install-recommends \ ca-certificates \ curl \ libmariadb3 \ libpq5 \ openssl && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* {% elif base == "alpine" %} apk --no-cache add \ ca-certificates \ curl \ openssl \ tzdata {% endif %} VOLUME /data EXPOSE 80 # Copies the files from the context (Rocket.toml file and web-vault) # and the binary from the "build" stage to the current stage WORKDIR / COPY docker/healthcheck.sh docker/start.sh / COPY --from=vault /web-vault ./web-vault COPY --from=build /app/target/final/vaultwarden . HEALTHCHECK --interval=60s --timeout=10s CMD ["/healthcheck.sh"] CMD ["/start.sh"] ================================================ FILE: docker/Makefile ================================================ all: ./render_template Dockerfile.j2 '{"base": "debian"}' > Dockerfile.debian ./render_template Dockerfile.j2 '{"base": "alpine"}' > Dockerfile.alpine .PHONY: all ================================================ FILE: docker/README.md ================================================ # Vaultwarden Container Building To build and release new testing and stable releases of Vaultwarden we use `docker buildx bake`.
This can be used locally by running the command yourself, but it is also used by GitHub Actions. This makes it easier for us to test and maintain the different architectures we provide.
We also just have two Dockerfile's one for Debian and one for Alpine based images.
With just these two files we can build both Debian and Alpine images for the following platforms: - amd64 (linux/amd64) - arm64 (linux/arm64) - armv7 (linux/arm/v7) - armv6 (linux/arm/v6) Some unsupported platforms for Debian based images. These are not built and tested by default and are only provided to make it easier for users to build for these architectures. - 386 (linux/386) - ppc64le (linux/ppc64le) - s390x (linux/s390x) To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.
This ensures the container build process can run binaries from other architectures.
**NOTE**: Run all the examples below from the root of the repo.
## How to install QEMU binfmt support This is different per host OS, but most support this in some way.
### Ubuntu/Debian ```bash apt install binfmt-support qemu-user-static ``` ### Arch Linux (others based upon it) ```bash pacman -S qemu-user-static qemu-user-static-binfmt ``` ### Fedora ```bash dnf install qemu-user-static ``` ### Others There also is an option to use an other docker container to provide support for this. ```bash # To install and activate docker run --privileged --rm tonistiigi/binfmt --install arm64,arm # To uninstall docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' ``` ## Single architecture container building You can build a container per supported architecture as long as you have QEMU binfmt support installed on your system.
```bash # Default bake triggers a Debian build using the hosts architecture docker buildx bake --file docker/docker-bake.hcl # Bake Debian ARM64 using a debug build CARGO_PROFILE=dev \ SOURCE_COMMIT="$(git rev-parse HEAD)" \ docker buildx bake --file docker/docker-bake.hcl debian-arm64 # Bake Alpine ARMv6 as a release build SOURCE_COMMIT="$(git rev-parse HEAD)" \ docker buildx bake --file docker/docker-bake.hcl alpine-armv6 ``` ## Local Multi Architecture container building Start the initialization, this only needs to be done once. ```bash # Create and use a new buildx builder instance which connects to the host network docker buildx create --name vaultwarden --use --driver-opt network=host # Validate it runs docker buildx inspect --bootstrap # Create a local container registry directly reachable on the localhost docker run -d --name registry --network host registry:2 ``` After that is done, you should be able to build and push to the local registry.
Use the following command with the modified variables to bake the Alpine images.
Replace `alpine` with `debian` if you want to build the debian multi arch images. ```bash # Start a buildx bake using a debug build CARGO_PROFILE=dev \ SOURCE_COMMIT="$(git rev-parse HEAD)" \ CONTAINER_REGISTRIES="localhost:5000/vaultwarden/server" \ docker buildx bake --file docker/docker-bake.hcl alpine-multi ``` ## Using the `bake.sh` script To make it a bit more easier to trigger a build, there also is a `bake.sh` script.
This script calls `docker buildx bake` with all the right parameters and also generates the `SOURCE_COMMIT` and `SOURCE_VERSION` variables.
This script can be called from both the repo root or within the docker directory. So, if you want to build a Multi Arch Alpine container pushing to your localhost registry you can run this from within the docker directory. (Just make sure you executed the initialization steps above first) ```bash CONTAINER_REGISTRIES="localhost:5000/vaultwarden/server" \ ./bake.sh alpine-multi ``` Or if you want to just build a Debian container from the repo root, you can run this. ```bash docker/bake.sh ``` You can append both `alpine` and `debian` with `-amd64`, `-arm64`, `-armv7` or `-armv6`, which will trigger a build for that specific platform.
This will also append those values to the tag so you can see the built container when running `docker images`. You can also append extra arguments after the target if you want. This can be useful for example to print what bake will use. ```bash docker/bake.sh alpine-all --print ``` ### Testing baked images To test these images you can run these images by using the correct tag and provide the platform.
For example, after you have build an arm64 image via `./bake.sh debian-arm64` you can run: ```bash docker run --rm -it \ -e DISABLE_ADMIN_TOKEN=true \ -e I_REALLY_WANT_VOLATILE_STORAGE=true \ -p8080:80 --platform=linux/arm64 \ vaultwarden/server:testing-arm64 ``` ## Using the `podman-bake.sh` script To also make building easier using podman, there is a `podman-bake.sh` script.
This script calls `podman buildx build` with the needed parameters and the same as `bake.sh`, it will generate some variables automatically.
This script can be called from both the repo root or within the docker directory. **NOTE:** Unlike the `bake.sh` script, this only supports a single `CONTAINER_REGISTRIES`, and a single `BASE_TAGS` value, no comma separated values. It also only supports building separate architectures, no Multi Arch containers. To build an Alpine arm64 image with only sqlite support and mimalloc, run this: ```bash DB="sqlite,enable_mimalloc" \ ./podman-bake.sh alpine-arm64 ``` Or if you want to just build a Debian container from the repo root, you can run this. ```bash docker/podman-bake.sh ``` You can append extra arguments after the target if you want. This can be useful for example to disable cache like this. ```bash ./podman-bake.sh alpine-arm64 --no-cache ``` For the podman builds you can, just like the `bake.sh` script, also append the architecture to build for that specific platform.
### Testing podman built images The command to start a podman built container is almost the same as for the docker/bake built containers. The images start with `localhost/`, so you need to prepend that. ```bash podman run --rm -it \ -e DISABLE_ADMIN_TOKEN=true \ -e I_REALLY_WANT_VOLATILE_STORAGE=true \ -p8080:80 --platform=linux/arm64 \ localhost/vaultwarden/server:testing-arm64 ``` ## Variables supported | Variable | default | description | | --------------------- | ------------------ | ----------- | | CARGO_PROFILE | null | Which cargo profile to use. `null` means what is defined in the Dockerfile | | DB | null | Which `features` to build. `null` means what is defined in the Dockerfile | | SOURCE_REPOSITORY_URL | null | The source repository form where this build is triggered | | SOURCE_COMMIT | null | The commit hash of the current commit for this build | | SOURCE_VERSION | null | The current exact tag of this commit, else the last tag and the first 8 chars of the source commit | | BASE_TAGS | testing | Tags to be used. Can be a comma separated value like "latest,1.29.2" | | CONTAINER_REGISTRIES | vaultwarden/server | Comma separated value of container registries. Like `ghcr.io/dani-garcia/vaultwarden,docker.io/vaultwarden/server` | | VW_VERSION | null | To override the `SOURCE_VERSION` value. This is also used by the `build.rs` code for example | ================================================ FILE: docker/bake.sh ================================================ #!/usr/bin/env bash # Determine the basedir of this script. # It should be located in the same directory as the docker-bake.hcl # This ensures you can run this script from both inside and outside of the docker directory BASEDIR=$(RL=$(readlink -n "$0"); SP="${RL:-$0}"; dirname "$(cd "$(dirname "${SP}")" || exit; pwd)/$(basename "${SP}")") # Load build env's source "${BASEDIR}/bake_env.sh" # Be verbose on what is being executed set -x # Make sure we set the context to `..` so it will go up one directory docker buildx bake --progress plain --set "*.context=${BASEDIR}/.." -f "${BASEDIR}/docker-bake.hcl" "$@" ================================================ FILE: docker/bake_env.sh ================================================ #!/usr/bin/env bash # If SOURCE_COMMIT is provided via env skip this if [ -z "${SOURCE_COMMIT+x}" ]; then SOURCE_COMMIT="$(git rev-parse HEAD)" fi # If VW_VERSION is provided via env use it as SOURCE_VERSION # Else define it using git if [[ -n "${VW_VERSION}" ]]; then SOURCE_VERSION="${VW_VERSION}" else GIT_EXACT_TAG="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null)" if [[ -n "${GIT_EXACT_TAG}" ]]; then SOURCE_VERSION="${GIT_EXACT_TAG}" else GIT_LAST_TAG="$(git describe --tags --abbrev=0)" SOURCE_VERSION="${GIT_LAST_TAG}-${SOURCE_COMMIT:0:8}" GIT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" case "${GIT_BRANCH}" in main|master|HEAD) # Do not add the branch name for these branches ;; *) SOURCE_VERSION="${SOURCE_VERSION} (${GIT_BRANCH})" ;; esac fi fi # Export the rendered variables above so bake will use them export SOURCE_COMMIT export SOURCE_VERSION ================================================ FILE: docker/docker-bake.hcl ================================================ // ==== Baking Variables ==== // Set which cargo profile to use, dev or release for example // Use the value provided in the Dockerfile as default variable "CARGO_PROFILE" { default = null } // Set which DB's (features) to enable // Use the value provided in the Dockerfile as default variable "DB" { default = null } // The repository this build was triggered from variable "SOURCE_REPOSITORY_URL" { default = null } // The commit hash of the current commit this build was triggered on variable "SOURCE_COMMIT" { default = null } // The version of this build // Typically the current exact tag of this commit, // else the last tag and the first 8 characters of the source commit variable "SOURCE_VERSION" { default = null } // This can be used to overwrite SOURCE_VERSION // It will be used during the build.rs building stage variable "VW_VERSION" { default = null } // The base tag(s) to use // This can be a comma separated value like "testing,1.29.2" variable "BASE_TAGS" { default = "testing" } // Which container registries should be used for the tagging // This can be a comma separated value // Use a full URI like `ghcr.io/dani-garcia/vaultwarden,docker.io/vaultwarden/server` variable "CONTAINER_REGISTRIES" { default = "vaultwarden/server" } // ==== Baking Groups ==== group "default" { targets = ["debian"] } // ==== Shared Baking ==== function "labels" { params = [] result = { "org.opencontainers.image.description" = "Unofficial Bitwarden compatible server written in Rust - ${SOURCE_VERSION}" "org.opencontainers.image.licenses" = "AGPL-3.0-only" "org.opencontainers.image.documentation" = "https://github.com/dani-garcia/vaultwarden/wiki" "org.opencontainers.image.url" = "https://github.com/dani-garcia/vaultwarden" "org.opencontainers.image.created" = "${formatdate("YYYY-MM-DD'T'hh:mm:ssZZZZZ", timestamp())}" "org.opencontainers.image.source" = "${SOURCE_REPOSITORY_URL}" "org.opencontainers.image.revision" = "${SOURCE_COMMIT}" "org.opencontainers.image.version" = "${SOURCE_VERSION}" } } target "_default_attributes" { labels = labels() args = { DB = "${DB}" CARGO_PROFILE = "${CARGO_PROFILE}" VW_VERSION = "${VW_VERSION}" } } // ==== Debian Baking ==== // Default Debian target, will build a container using the hosts platform architecture target "debian" { inherits = ["_default_attributes"] dockerfile = "docker/Dockerfile.debian" tags = generate_tags("", platform_tag()) output = ["type=docker"] } // Multi Platform target, will build one tagged manifest with all supported architectures // This is mainly used by GitHub Actions to build and push new containers target "debian-multi" { inherits = ["debian"] platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] tags = generate_tags("", "") output = [join(",", flatten([["type=registry"], image_index_annotations()]))] } // Per platform targets, to individually test building per platform locally target "debian-amd64" { inherits = ["debian"] platforms = ["linux/amd64"] tags = generate_tags("", "-amd64") } target "debian-arm64" { inherits = ["debian"] platforms = ["linux/arm64"] tags = generate_tags("", "-arm64") } target "debian-armv7" { inherits = ["debian"] platforms = ["linux/arm/v7"] tags = generate_tags("", "-armv7") } target "debian-armv6" { inherits = ["debian"] platforms = ["linux/arm/v6"] tags = generate_tags("", "-armv6") } // ==== Start of unsupported Debian architecture targets === // These are provided just to help users build for these rare platforms // They will not be built by default target "debian-386" { inherits = ["debian"] platforms = ["linux/386"] tags = generate_tags("", "-386") args = { TARGET_PKG_CONFIG_PATH = "/usr/lib/i386-linux-gnu/pkgconfig" } } target "debian-ppc64le" { inherits = ["debian"] platforms = ["linux/ppc64le"] tags = generate_tags("", "-ppc64le") } target "debian-s390x" { inherits = ["debian"] platforms = ["linux/s390x"] tags = generate_tags("", "-s390x") } // ==== End of unsupported Debian architecture targets === // A Group to build all platforms individually for local testing group "debian-all" { targets = ["debian-amd64", "debian-arm64", "debian-armv7", "debian-armv6"] } // ==== Alpine Baking ==== // Default Alpine target, will build a container using the hosts platform architecture target "alpine" { inherits = ["_default_attributes"] dockerfile = "docker/Dockerfile.alpine" tags = generate_tags("-alpine", platform_tag()) output = ["type=docker"] } // Multi Platform target, will build one tagged manifest with all supported architectures // This is mainly used by GitHub Actions to build and push new containers target "alpine-multi" { inherits = ["alpine"] platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] tags = generate_tags("-alpine", "") output = [join(",", flatten([["type=registry"], image_index_annotations()]))] } // Per platform targets, to individually test building per platform locally target "alpine-amd64" { inherits = ["alpine"] platforms = ["linux/amd64"] tags = generate_tags("-alpine", "-amd64") } target "alpine-arm64" { inherits = ["alpine"] platforms = ["linux/arm64"] tags = generate_tags("-alpine", "-arm64") } target "alpine-armv7" { inherits = ["alpine"] platforms = ["linux/arm/v7"] tags = generate_tags("-alpine", "-armv7") } target "alpine-armv6" { inherits = ["alpine"] platforms = ["linux/arm/v6"] tags = generate_tags("-alpine", "-armv6") } // A Group to build all platforms individually for local testing group "alpine-all" { targets = ["alpine-amd64", "alpine-arm64", "alpine-armv7", "alpine-armv6"] } // ==== Bake everything locally ==== group "all" { targets = ["debian-all", "alpine-all"] } // ==== Baking functions ==== // This will return the local platform as amd64, arm64 or armv7 for example // It can be used for creating a local image tag function "platform_tag" { params = [] result = "-${replace(replace(BAKE_LOCAL_PLATFORM, "linux/", ""), "/", "")}" } function "get_container_registries" { params = [] result = flatten(split(",", CONTAINER_REGISTRIES)) } function "get_base_tags" { params = [] result = flatten(split(",", BASE_TAGS)) } function "generate_tags" { params = [ suffix, // What to append to the BASE_TAG when needed, like `-alpine` for example platform // the platform we are building for if needed ] result = flatten([ for registry in get_container_registries() : [for base_tag in get_base_tags() : concat( # If the base_tag contains latest, and the suffix contains `-alpine` add a `:alpine` tag too base_tag == "latest" ? suffix == "-alpine" ? ["${registry}:alpine${platform}"] : [] : [], # The default tagging strategy ["${registry}:${base_tag}${suffix}${platform}"] ) ] ]) } function "image_index_annotations" { params = [] result = flatten([ for key, value in labels() : value != null ? formatlist("annotation-index.%s=%s", "${key}", "${value}") : [] ]) } ================================================ FILE: docker/healthcheck.sh ================================================ #!/usr/bin/env sh # Use the value of the corresponding env var (if present), # or a default value otherwise. : "${DATA_FOLDER:="/data"}" : "${ROCKET_PORT:="80"}" : "${ENV_FILE:="/.env"}" CONFIG_FILE="${DATA_FOLDER}"/config.json # Check if the $ENV_FILE file exist and is readable # If that is the case, load it into the environment before running any check if [ -r "${ENV_FILE}" ]; then # shellcheck disable=SC1090 . "${ENV_FILE}" fi # 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() { 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 addr="${ROCKET_ADDRESS}" if [ -z "${addr}" ] || [ "${addr}" = '0.0.0.0' ] || [ "${addr}" = '::' ]; then addr='localhost' fi base_path="$(get_base_path "${DOMAIN}")" if [ -n "${ROCKET_TLS}" ]; then s='s' fi curl --insecure --fail --silent --show-error \ "http${s}://${addr}:${ROCKET_PORT}${base_path}/alive" || exit 1 ================================================ FILE: docker/podman-bake.sh ================================================ #!/usr/bin/env bash # Determine the basedir of this script. # It should be located in the same directory as the docker-bake.hcl # This ensures you can run this script from both inside and outside of the docker directory BASEDIR=$(RL=$(readlink -n "$0"); SP="${RL:-$0}"; dirname "$(cd "$(dirname "${SP}")" || exit; pwd)/$(basename "${SP}")") # Load build env's source "${BASEDIR}/bake_env.sh" # Check if a target is given as first argument # If not we assume the defaults and pass the given arguments to the podman command case "${1}" in alpine*|debian*) TARGET="${1}" # Now shift the $@ array so we only have the rest of the arguments # This allows us too append these as extra arguments too the podman buildx build command shift ;; esac LABEL_ARGS=( --label org.opencontainers.image.description="Unofficial Bitwarden compatible server written in Rust" --label org.opencontainers.image.licenses="AGPL-3.0-only" --label org.opencontainers.image.documentation="https://github.com/dani-garcia/vaultwarden/wiki" --label org.opencontainers.image.url="https://github.com/dani-garcia/vaultwarden" --label org.opencontainers.image.created="$(date --utc --iso-8601=seconds)" ) if [[ -n "${SOURCE_REPOSITORY_URL}" ]]; then LABEL_ARGS+=(--label org.opencontainers.image.source="${SOURCE_REPOSITORY_URL}") fi if [[ -n "${SOURCE_COMMIT}" ]]; then LABEL_ARGS+=(--label org.opencontainers.image.revision="${SOURCE_COMMIT}") fi if [[ -n "${SOURCE_VERSION}" ]]; then LABEL_ARGS+=(--label org.opencontainers.image.version="${SOURCE_VERSION}") fi # Check if and which --build-arg arguments we need to configure BUILD_ARGS=() if [[ -n "${DB}" ]]; then BUILD_ARGS+=(--build-arg DB="${DB}") fi if [[ -n "${CARGO_PROFILE}" ]]; then BUILD_ARGS+=(--build-arg CARGO_PROFILE="${CARGO_PROFILE}") fi if [[ -n "${VW_VERSION}" ]]; then BUILD_ARGS+=(--build-arg VW_VERSION="${VW_VERSION}") fi # Set the default BASE_TAGS if non are provided if [[ -z "${BASE_TAGS}" ]]; then BASE_TAGS="testing" fi # Set the default CONTAINER_REGISTRIES if non are provided if [[ -z "${CONTAINER_REGISTRIES}" ]]; then CONTAINER_REGISTRIES="vaultwarden/server" fi # Check which Dockerfile we need to use, default is debian case "${TARGET}" in alpine*) BASE_TAGS="${BASE_TAGS}-alpine" DOCKERFILE="Dockerfile.alpine" ;; *) DOCKERFILE="Dockerfile.debian" ;; esac # Check which platform we need to build and append the BASE_TAGS with the architecture case "${TARGET}" in *-arm64) BASE_TAGS="${BASE_TAGS}-arm64" PLATFORM="linux/arm64" ;; *-armv7) BASE_TAGS="${BASE_TAGS}-armv7" PLATFORM="linux/arm/v7" ;; *-armv6) BASE_TAGS="${BASE_TAGS}-armv6" PLATFORM="linux/arm/v6" ;; *) BASE_TAGS="${BASE_TAGS}-amd64" PLATFORM="linux/amd64" ;; esac # Be verbose on what is being executed set -x # Build the image with podman # We use the docker format here since we are using `SHELL`, which is not supported by OCI # shellcheck disable=SC2086 podman buildx build \ --platform="${PLATFORM}" \ --tag="${CONTAINER_REGISTRIES}:${BASE_TAGS}" \ --format=docker \ "${LABEL_ARGS[@]}" \ "${BUILD_ARGS[@]}" \ --file="${BASEDIR}/${DOCKERFILE}" "$@" \ "${BASEDIR}/.." ================================================ FILE: docker/render_template ================================================ #!/usr/bin/env python3 import os import argparse import json import yaml import jinja2 # Load settings file with open("DockerSettings.yaml", 'r') as yaml_file: yaml_data = yaml.safe_load(yaml_file) settings_env = jinja2.Environment( loader=jinja2.FileSystemLoader(os.getcwd()), ) settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data)) args_parser = argparse.ArgumentParser() args_parser.add_argument('template_file', help='Jinja2 template file to render.') args_parser.add_argument('render_vars', help='JSON-encoded data to pass to the templating engine.') cli_args = args_parser.parse_args() # Merge the default config yaml with the json arguments given. render_vars = json.loads(cli_args.render_vars) settings_yaml.update(render_vars) environment = jinja2.Environment( loader=jinja2.FileSystemLoader(os.getcwd()), trim_blocks=True, ) print(environment.get_template(cli_args.template_file).render(settings_yaml)) ================================================ FILE: docker/start.sh ================================================ #!/bin/sh if [ -n "${UMASK}" ]; then umask "${UMASK}" fi if [ -r /etc/vaultwarden.sh ]; then . /etc/vaultwarden.sh elif [ -r /etc/bitwarden_rs.sh ]; then echo "### You are using the old /etc/bitwarden_rs.sh script, please migrate to /etc/vaultwarden.sh ###" . /etc/bitwarden_rs.sh fi if [ -d /etc/vaultwarden.d ]; then for f in /etc/vaultwarden.d/*.sh; do if [ -r "${f}" ]; then . "${f}" fi done elif [ -d /etc/bitwarden_rs.d ]; then echo "### You are using the old /etc/bitwarden_rs.d script directory, please migrate to /etc/vaultwarden.d ###" for f in /etc/bitwarden_rs.d/*.sh; do if [ -r "${f}" ]; then . "${f}" fi done fi exec /vaultwarden "${@}" ================================================ FILE: macros/Cargo.toml ================================================ [package] name = "macros" version = "0.1.0" repository.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true publish.workspace = true [lib] name = "macros" path = "src/lib.rs" proc-macro = true [dependencies] quote = "1.0.45" syn = "2.0.117" [lints] workspace = true ================================================ FILE: macros/src/lib.rs ================================================ use proc_macro::TokenStream; use quote::quote; #[proc_macro_derive(UuidFromParam)] pub fn derive_uuid_from_param(input: TokenStream) -> TokenStream { let ast = syn::parse(input).unwrap(); impl_derive_uuid_macro(&ast) } fn impl_derive_uuid_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; let gen_derive = quote! { #[automatically_derived] impl<'r> rocket::request::FromParam<'r> for #name { type Error = (); #[inline(always)] fn from_param(param: &'r str) -> Result { if uuid::Uuid::parse_str(param).is_ok() { Ok(Self(param.to_string())) } else { Err(()) } } } }; gen_derive.into() } #[proc_macro_derive(IdFromParam)] pub fn derive_id_from_param(input: TokenStream) -> TokenStream { let ast = syn::parse(input).unwrap(); impl_derive_safestring_macro(&ast) } fn impl_derive_safestring_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; let gen_derive = quote! { #[automatically_derived] impl<'r> rocket::request::FromParam<'r> for #name { type Error = (); #[inline(always)] fn from_param(param: &'r str) -> Result { if param.chars().all(|c| matches!(c, 'a'..='z' | 'A'..='Z' |'0'..='9' | '-')) { Ok(Self(param.to_string())) } else { Err(()) } } } }; gen_derive.into() } ================================================ FILE: migrations/mysql/2018-01-14-171611_create_tables/down.sql ================================================ DROP TABLE users; DROP TABLE devices; DROP TABLE ciphers; DROP TABLE attachments; DROP TABLE folders; ================================================ FILE: migrations/mysql/2018-01-14-171611_create_tables/up.sql ================================================ CREATE TABLE users ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, name TEXT NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, password_iterations INTEGER NOT NULL, password_hint TEXT, `key` TEXT NOT NULL, private_key TEXT, public_key TEXT, totp_secret TEXT, totp_recover TEXT, security_stamp TEXT NOT NULL, equivalent_domains TEXT NOT NULL, excluded_globals TEXT NOT NULL ); CREATE TABLE devices ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), name TEXT NOT NULL, type INTEGER NOT NULL, push_token TEXT, refresh_token TEXT NOT NULL ); CREATE TABLE ciphers ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), folder_uuid CHAR(36) REFERENCES folders (uuid), organization_uuid CHAR(36), type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, favorite BOOLEAN NOT NULL ); CREATE TABLE attachments ( id CHAR(36) NOT NULL PRIMARY KEY, cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), file_name TEXT NOT NULL, file_size INTEGER NOT NULL ); CREATE TABLE folders ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), name TEXT NOT NULL ); ================================================ FILE: migrations/mysql/2018-02-17-205753_create_collections_and_orgs/down.sql ================================================ DROP TABLE collections; DROP TABLE organizations; DROP TABLE users_collections; DROP TABLE users_organizations; ================================================ FILE: migrations/mysql/2018-02-17-205753_create_collections_and_orgs/up.sql ================================================ CREATE TABLE collections ( uuid VARCHAR(40) NOT NULL PRIMARY KEY, org_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid), name TEXT NOT NULL ); CREATE TABLE organizations ( uuid VARCHAR(40) NOT NULL PRIMARY KEY, name TEXT NOT NULL, billing_email TEXT NOT NULL ); CREATE TABLE users_collections ( user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), collection_uuid CHAR(36) NOT NULL REFERENCES collections (uuid), PRIMARY KEY (user_uuid, collection_uuid) ); CREATE TABLE users_organizations ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), access_all BOOLEAN NOT NULL, `key` TEXT NOT NULL, status INTEGER NOT NULL, type INTEGER NOT NULL, UNIQUE (user_uuid, org_uuid) ); ================================================ FILE: migrations/mysql/2018-04-27-155151_create_users_ciphers/down.sql ================================================ ================================================ FILE: migrations/mysql/2018-04-27-155151_create_users_ciphers/up.sql ================================================ ALTER TABLE ciphers RENAME TO oldCiphers; CREATE TABLE ciphers ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid CHAR(36) REFERENCES users (uuid), -- Make this optional organization_uuid CHAR(36) REFERENCES organizations (uuid), -- Add reference to orgs table -- Remove folder_uuid type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, favorite BOOLEAN NOT NULL ); CREATE TABLE folders_ciphers ( cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), folder_uuid CHAR(36) NOT NULL REFERENCES folders (uuid), PRIMARY KEY (cipher_uuid, folder_uuid) ); INSERT INTO ciphers (uuid, created_at, updated_at, user_uuid, organization_uuid, type, name, notes, fields, data, favorite) SELECT uuid, created_at, updated_at, user_uuid, organization_uuid, type, name, notes, fields, data, favorite FROM oldCiphers; INSERT INTO folders_ciphers (cipher_uuid, folder_uuid) SELECT uuid, folder_uuid FROM oldCiphers WHERE folder_uuid IS NOT NULL; DROP TABLE oldCiphers; ALTER TABLE users_collections ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT 0; -- False ================================================ FILE: migrations/mysql/2018-05-08-161616_create_collection_cipher_map/down.sql ================================================ DROP TABLE ciphers_collections; ================================================ FILE: migrations/mysql/2018-05-08-161616_create_collection_cipher_map/up.sql ================================================ CREATE TABLE ciphers_collections ( cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), collection_uuid CHAR(36) NOT NULL REFERENCES collections (uuid), PRIMARY KEY (cipher_uuid, collection_uuid) ); ================================================ FILE: migrations/mysql/2018-05-25-232323_update_attachments_reference/down.sql ================================================ ================================================ FILE: migrations/mysql/2018-05-25-232323_update_attachments_reference/up.sql ================================================ ALTER TABLE attachments RENAME TO oldAttachments; CREATE TABLE attachments ( id CHAR(36) NOT NULL PRIMARY KEY, cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), file_name TEXT NOT NULL, file_size INTEGER NOT NULL ); INSERT INTO attachments (id, cipher_uuid, file_name, file_size) SELECT id, cipher_uuid, file_name, file_size FROM oldAttachments; DROP TABLE oldAttachments; ================================================ FILE: migrations/mysql/2018-06-01-112529_update_devices_twofactor_remember/down.sql ================================================ -- This file should undo anything in `up.sql` ================================================ FILE: migrations/mysql/2018-06-01-112529_update_devices_twofactor_remember/up.sql ================================================ ALTER TABLE devices ADD COLUMN twofactor_remember TEXT; ================================================ FILE: migrations/mysql/2018-07-11-181453_create_u2f_twofactor/down.sql ================================================ UPDATE users SET totp_secret = ( SELECT twofactor.data FROM twofactor WHERE twofactor.type = 0 AND twofactor.user_uuid = users.uuid ); DROP TABLE twofactor; ================================================ FILE: migrations/mysql/2018-07-11-181453_create_u2f_twofactor/up.sql ================================================ CREATE TABLE twofactor ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), type INTEGER NOT NULL, enabled BOOLEAN NOT NULL, data TEXT NOT NULL, UNIQUE (user_uuid, type) ); INSERT INTO twofactor (uuid, user_uuid, type, enabled, data) SELECT UUID(), uuid, 0, 1, u.totp_secret FROM users u where u.totp_secret IS NOT NULL; UPDATE users SET totp_secret = NULL; -- Instead of recreating the table, just leave the columns empty ================================================ FILE: migrations/mysql/2018-08-27-172114_update_ciphers/down.sql ================================================ ================================================ FILE: migrations/mysql/2018-08-27-172114_update_ciphers/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN password_history TEXT; ================================================ FILE: migrations/mysql/2018-09-10-111213_add_invites/down.sql ================================================ DROP TABLE invitations; ================================================ FILE: migrations/mysql/2018-09-10-111213_add_invites/up.sql ================================================ CREATE TABLE invitations ( email VARCHAR(255) NOT NULL PRIMARY KEY ); ================================================ FILE: migrations/mysql/2018-09-19-144557_add_kdf_columns/down.sql ================================================ ================================================ FILE: migrations/mysql/2018-09-19-144557_add_kdf_columns/up.sql ================================================ ALTER TABLE users ADD COLUMN client_kdf_type INTEGER NOT NULL DEFAULT 0; -- PBKDF2 ALTER TABLE users ADD COLUMN client_kdf_iter INTEGER NOT NULL DEFAULT 100000; ================================================ FILE: migrations/mysql/2018-11-27-152651_add_att_key_columns/down.sql ================================================ ================================================ FILE: migrations/mysql/2018-11-27-152651_add_att_key_columns/up.sql ================================================ ALTER TABLE attachments ADD COLUMN `key` TEXT; ================================================ FILE: migrations/mysql/2019-05-26-216651_rename_key_and_type_columns/down.sql ================================================ ALTER TABLE attachments CHANGE COLUMN akey `key` TEXT; ALTER TABLE ciphers CHANGE COLUMN atype type INTEGER NOT NULL; ALTER TABLE devices CHANGE COLUMN atype type INTEGER NOT NULL; ALTER TABLE twofactor CHANGE COLUMN atype type INTEGER NOT NULL; ALTER TABLE users CHANGE COLUMN akey `key` TEXT; ALTER TABLE users_organizations CHANGE COLUMN akey `key` TEXT; ALTER TABLE users_organizations CHANGE COLUMN atype type INTEGER NOT NULL; ================================================ FILE: migrations/mysql/2019-05-26-216651_rename_key_and_type_columns/up.sql ================================================ ALTER TABLE attachments CHANGE COLUMN `key` akey TEXT; ALTER TABLE ciphers CHANGE COLUMN type atype INTEGER NOT NULL; ALTER TABLE devices CHANGE COLUMN type atype INTEGER NOT NULL; ALTER TABLE twofactor CHANGE COLUMN type atype INTEGER NOT NULL; ALTER TABLE users CHANGE COLUMN `key` akey TEXT; ALTER TABLE users_organizations CHANGE COLUMN `key` akey TEXT; ALTER TABLE users_organizations CHANGE COLUMN type atype INTEGER NOT NULL; ================================================ FILE: migrations/mysql/2019-10-10-083032_add_column_to_twofactor/down.sql ================================================ ================================================ FILE: migrations/mysql/2019-10-10-083032_add_column_to_twofactor/up.sql ================================================ ALTER TABLE twofactor ADD COLUMN last_used INTEGER NOT NULL DEFAULT 0; ================================================ FILE: migrations/mysql/2019-11-17-011009_add_email_verification/down.sql ================================================ ================================================ FILE: migrations/mysql/2019-11-17-011009_add_email_verification/up.sql ================================================ ALTER TABLE users ADD COLUMN verified_at DATETIME DEFAULT NULL; ALTER TABLE users ADD COLUMN last_verifying_at DATETIME DEFAULT NULL; ALTER TABLE users ADD COLUMN login_verify_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN email_new VARCHAR(255) DEFAULT NULL; ALTER TABLE users ADD COLUMN email_new_token VARCHAR(16) DEFAULT NULL; ================================================ FILE: migrations/mysql/2020-03-13-205045_add_policy_table/down.sql ================================================ DROP TABLE org_policies; ================================================ FILE: migrations/mysql/2020-03-13-205045_add_policy_table/up.sql ================================================ 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) ); ================================================ FILE: migrations/mysql/2020-04-09-235005_add_cipher_delete_date/down.sql ================================================ ================================================ FILE: migrations/mysql/2020-04-09-235005_add_cipher_delete_date/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN deleted_at DATETIME; ================================================ FILE: migrations/mysql/2020-07-01-214531_add_hide_passwords/down.sql ================================================ ================================================ FILE: migrations/mysql/2020-07-01-214531_add_hide_passwords/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/mysql/2020-08-02-025025_add_favorites_table/down.sql ================================================ ALTER TABLE ciphers ADD COLUMN favorite BOOLEAN NOT NULL DEFAULT FALSE; -- Transfer favorite status for user-owned ciphers. UPDATE ciphers SET favorite = TRUE WHERE EXISTS ( SELECT * FROM favorites WHERE favorites.user_uuid = ciphers.user_uuid AND favorites.cipher_uuid = ciphers.uuid ); DROP TABLE favorites; ================================================ FILE: migrations/mysql/2020-08-02-025025_add_favorites_table/up.sql ================================================ CREATE TABLE favorites ( user_uuid CHAR(36) NOT NULL REFERENCES users(uuid), cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers(uuid), PRIMARY KEY (user_uuid, cipher_uuid) ); -- Transfer favorite status for user-owned ciphers. INSERT INTO favorites(user_uuid, cipher_uuid) SELECT user_uuid, uuid FROM ciphers WHERE favorite = TRUE AND user_uuid IS NOT NULL; ALTER TABLE ciphers DROP COLUMN favorite; ================================================ FILE: migrations/mysql/2020-11-30-224000_add_user_enabled/down.sql ================================================ ================================================ FILE: migrations/mysql/2020-11-30-224000_add_user_enabled/up.sql ================================================ ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1; ================================================ FILE: migrations/mysql/2020-12-09-173101_add_stamp_exception/down.sql ================================================ ================================================ FILE: migrations/mysql/2020-12-09-173101_add_stamp_exception/up.sql ================================================ ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL; ================================================ FILE: migrations/mysql/2021-03-11-190243_add_sends/down.sql ================================================ DROP TABLE sends; ================================================ FILE: migrations/mysql/2021-03-11-190243_add_sends/up.sql ================================================ CREATE TABLE sends ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) REFERENCES users (uuid), organization_uuid CHAR(36) REFERENCES organizations (uuid), name TEXT NOT NULL, notes TEXT, atype INTEGER NOT NULL, data TEXT NOT NULL, akey TEXT NOT NULL, password_hash BLOB, password_salt BLOB, password_iter INTEGER, max_access_count INTEGER, access_count INTEGER NOT NULL, creation_date DATETIME NOT NULL, revision_date DATETIME NOT NULL, expiration_date DATETIME, deletion_date DATETIME NOT NULL, disabled BOOLEAN NOT NULL ); ================================================ FILE: migrations/mysql/2021-04-30-233251_add_reprompt/down.sql ================================================ ================================================ FILE: migrations/mysql/2021-04-30-233251_add_reprompt/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN reprompt INTEGER; ================================================ FILE: migrations/mysql/2021-05-11-205202_add_hide_email/down.sql ================================================ ================================================ FILE: migrations/mysql/2021-05-11-205202_add_hide_email/up.sql ================================================ ALTER TABLE sends ADD COLUMN hide_email BOOLEAN; ================================================ FILE: migrations/mysql/2021-07-01-203140_add_password_reset_keys/down.sql ================================================ ================================================ FILE: migrations/mysql/2021-07-01-203140_add_password_reset_keys/up.sql ================================================ ALTER TABLE organizations ADD COLUMN private_key TEXT; ALTER TABLE organizations ADD COLUMN public_key TEXT; ================================================ FILE: migrations/mysql/2021-08-30-193501_create_emergency_access/down.sql ================================================ DROP TABLE emergency_access; ================================================ FILE: migrations/mysql/2021-08-30-193501_create_emergency_access/up.sql ================================================ CREATE TABLE emergency_access ( uuid CHAR(36) NOT NULL PRIMARY KEY, grantor_uuid CHAR(36) REFERENCES users (uuid), grantee_uuid CHAR(36) REFERENCES users (uuid), email VARCHAR(255), key_encrypted TEXT, atype INTEGER NOT NULL, status INTEGER NOT NULL, wait_time_days INTEGER NOT NULL, recovery_initiated_at DATETIME, last_notification_at DATETIME, updated_at DATETIME NOT NULL, created_at DATETIME NOT NULL ); ================================================ FILE: migrations/mysql/2021-10-24-164321_add_2fa_incomplete/down.sql ================================================ DROP TABLE twofactor_incomplete; ================================================ FILE: migrations/mysql/2021-10-24-164321_add_2fa_incomplete/up.sql ================================================ CREATE TABLE twofactor_incomplete ( user_uuid CHAR(36) NOT NULL REFERENCES users(uuid), device_uuid CHAR(36) NOT NULL, device_name TEXT NOT NULL, login_time DATETIME NOT NULL, ip_address TEXT NOT NULL, PRIMARY KEY (user_uuid, device_uuid) ); ================================================ FILE: migrations/mysql/2022-01-17-234911_add_api_key/down.sql ================================================ ================================================ FILE: migrations/mysql/2022-01-17-234911_add_api_key/up.sql ================================================ ALTER TABLE users ADD COLUMN api_key VARCHAR(255); ================================================ FILE: migrations/mysql/2022-03-02-210038_update_devices_primary_key/down.sql ================================================ ================================================ FILE: migrations/mysql/2022-03-02-210038_update_devices_primary_key/up.sql ================================================ -- First remove the previous primary key ALTER TABLE devices DROP PRIMARY KEY; -- Add a new combined one ALTER TABLE devices ADD PRIMARY KEY (uuid, user_uuid); ================================================ FILE: migrations/mysql/2022-07-27-110000_add_group_support/down.sql ================================================ DROP TABLE `groups`; DROP TABLE groups_users; DROP TABLE collections_groups; ================================================ FILE: migrations/mysql/2022-07-27-110000_add_group_support/up.sql ================================================ CREATE TABLE `groups` ( uuid CHAR(36) NOT NULL PRIMARY KEY, organizations_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid), name VARCHAR(100) NOT NULL, access_all BOOLEAN NOT NULL, external_id VARCHAR(300) NULL, creation_date DATETIME NOT NULL, revision_date DATETIME NOT NULL ); CREATE TABLE groups_users ( groups_uuid CHAR(36) NOT NULL REFERENCES `groups` (uuid), users_organizations_uuid VARCHAR(36) NOT NULL REFERENCES users_organizations (uuid), UNIQUE (groups_uuid, users_organizations_uuid) ); CREATE TABLE collections_groups ( collections_uuid VARCHAR(40) NOT NULL REFERENCES collections (uuid), groups_uuid CHAR(36) NOT NULL REFERENCES `groups` (uuid), read_only BOOLEAN NOT NULL, hide_passwords BOOLEAN NOT NULL, UNIQUE (collections_uuid, groups_uuid) ); ================================================ FILE: migrations/mysql/2022-10-18-170602_add_events/down.sql ================================================ DROP TABLE event; ================================================ FILE: migrations/mysql/2022-10-18-170602_add_events/up.sql ================================================ CREATE TABLE event ( uuid CHAR(36) NOT NULL PRIMARY KEY, event_type INTEGER NOT NULL, user_uuid CHAR(36), org_uuid CHAR(36), cipher_uuid CHAR(36), collection_uuid CHAR(36), group_uuid CHAR(36), org_user_uuid CHAR(36), act_user_uuid CHAR(36), device_type INTEGER, ip_address TEXT, event_date DATETIME NOT NULL, policy_uuid CHAR(36), provider_uuid CHAR(36), provider_user_uuid CHAR(36), provider_org_uuid CHAR(36), UNIQUE (uuid) ); ================================================ FILE: migrations/mysql/2023-01-06-151600_add_reset_password_support/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-01-06-151600_add_reset_password_support/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN reset_password_key TEXT; ================================================ FILE: migrations/mysql/2023-01-11-205851_add_avatar_color/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-01-11-205851_add_avatar_color/up.sql ================================================ ALTER TABLE users ADD COLUMN avatar_color VARCHAR(7); ================================================ FILE: migrations/mysql/2023-01-31-222222_add_argon2/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-01-31-222222_add_argon2/up.sql ================================================ ALTER TABLE users ADD COLUMN client_kdf_memory INTEGER DEFAULT NULL; ALTER TABLE users ADD COLUMN client_kdf_parallelism INTEGER DEFAULT NULL; ================================================ FILE: migrations/mysql/2023-02-18-125735_push_uuid_table/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-02-18-125735_push_uuid_table/up.sql ================================================ ALTER TABLE devices ADD COLUMN push_uuid TEXT; ================================================ FILE: migrations/mysql/2023-06-02-200424_create_organization_api_key/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-06-02-200424_create_organization_api_key/up.sql ================================================ CREATE TABLE organization_api_key ( uuid CHAR(36) NOT NULL, org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), atype INTEGER NOT NULL, api_key VARCHAR(255) NOT NULL, revision_date DATETIME NOT NULL, PRIMARY KEY(uuid, org_uuid) ); ALTER TABLE users ADD COLUMN external_id TEXT; ================================================ FILE: migrations/mysql/2023-06-17-200424_create_auth_requests_table/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-06-17-200424_create_auth_requests_table/up.sql ================================================ CREATE TABLE auth_requests ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL, organization_uuid CHAR(36), request_device_identifier CHAR(36) NOT NULL, device_type INTEGER NOT NULL, request_ip TEXT NOT NULL, response_device_id CHAR(36), access_code TEXT NOT NULL, public_key TEXT NOT NULL, enc_key TEXT NOT NULL, master_password_hash TEXT NOT NULL, approved BOOLEAN, creation_date DATETIME NOT NULL, response_date DATETIME, authentication_date DATETIME, FOREIGN KEY(user_uuid) REFERENCES users(uuid), FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) ); ================================================ FILE: migrations/mysql/2023-06-28-133700_add_collection_external_id/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-06-28-133700_add_collection_external_id/up.sql ================================================ ALTER TABLE collections ADD COLUMN external_id TEXT; ================================================ FILE: migrations/mysql/2023-09-01-170620_update_auth_request_table/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-09-01-170620_update_auth_request_table/up.sql ================================================ ALTER TABLE auth_requests MODIFY master_password_hash TEXT; ALTER TABLE auth_requests MODIFY enc_key TEXT; ================================================ FILE: migrations/mysql/2023-09-02-212336_move_user_external_id/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-09-02-212336_move_user_external_id/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN external_id TEXT; ================================================ FILE: migrations/mysql/2023-09-10-133000_add_sso/down.sql ================================================ DROP TABLE sso_nonce; ================================================ FILE: migrations/mysql/2023-09-10-133000_add_sso/up.sql ================================================ CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql ================================================ ALTER TABLE users_organizations DROP COLUMN invited_by_email; ================================================ FILE: migrations/mysql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; ================================================ FILE: migrations/mysql/2023-10-21-221242_add_cipher_key/down.sql ================================================ ================================================ FILE: migrations/mysql/2023-10-21-221242_add_cipher_key/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN `key` TEXT; ================================================ FILE: migrations/mysql/2024-01-12-210182_change_attachment_size/down.sql ================================================ ================================================ FILE: migrations/mysql/2024-01-12-210182_change_attachment_size/up.sql ================================================ ALTER TABLE attachments MODIFY file_size BIGINT NOT NULL; ================================================ FILE: migrations/mysql/2024-02-14-135828_change_time_stamp_data_type/down.sql ================================================ ================================================ FILE: migrations/mysql/2024-02-14-135828_change_time_stamp_data_type/up.sql ================================================ ALTER TABLE twofactor MODIFY last_used BIGINT NOT NULL; ================================================ FILE: migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/down.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state VARCHAR(512) NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state VARCHAR(512) NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state VARCHAR(512) NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/mysql/2024-03-06-170000_add_sso_users/down.sql ================================================ DROP TABLE IF EXISTS sso_users; ================================================ FILE: migrations/mysql/2024-03-06-170000_add_sso_users/up.sql ================================================ CREATE TABLE sso_users ( user_uuid CHAR(36) NOT NULL PRIMARY KEY, identifier VARCHAR(768) NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT now(), FOREIGN KEY(user_uuid) REFERENCES users(uuid) ); ================================================ FILE: migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql ================================================ ================================================ FILE: migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql ================================================ -- Dynamically create DROP FOREIGN KEY -- Some versions of MySQL or MariaDB might fail if the key doesn't exists -- This checks if the key exists, and if so, will drop it. SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'sso_users' AND CONSTRAINT_NAME = 'sso_users_ibfk_1' AND CONSTRAINT_TYPE = 'FOREIGN KEY') = true, 'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1', 'SELECT 1'); PREPARE stmt FROM @drop_sso_fk; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; ================================================ FILE: migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql ================================================ DROP TABLE twofactor_duo_ctx; ================================================ FILE: migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql ================================================ CREATE TABLE twofactor_duo_ctx ( state VARCHAR(64) NOT NULL, user_email VARCHAR(255) NOT NULL, nonce VARCHAR(64) NOT NULL, exp BIGINT NOT NULL, PRIMARY KEY (state) ); ================================================ FILE: migrations/mysql/2024-09-04-091351_use_device_type_for_mails/down.sql ================================================ ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`; ================================================ FILE: migrations/mysql/2024-09-04-091351_use_device_type_for_mails/up.sql ================================================ ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser ================================================ FILE: migrations/mysql/2025-01-09-172300_add_manage/down.sql ================================================ ================================================ FILE: migrations/mysql/2025-01-09-172300_add_manage/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE collections_groups ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/down.sql ================================================ DROP TABLE IF EXISTS sso_auth; CREATE TABLE sso_nonce ( state VARCHAR(512) NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/mysql/2025-08-20-120000_sso_nonce_to_auth/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_auth ( state VARCHAR(512) NOT NULL PRIMARY KEY, client_challenge TEXT NOT NULL, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_response TEXT, auth_response TEXT, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2019-09-12-100000_create_tables/down.sql ================================================ DROP TABLE devices; DROP TABLE attachments; DROP TABLE users_collections; DROP TABLE users_organizations; DROP TABLE folders_ciphers; DROP TABLE ciphers_collections; DROP TABLE twofactor; DROP TABLE invitations; DROP TABLE collections; DROP TABLE folders; DROP TABLE ciphers; DROP TABLE users; DROP TABLE organizations; ================================================ FILE: migrations/postgresql/2019-09-12-100000_create_tables/up.sql ================================================ CREATE TABLE users ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, name TEXT NOT NULL, password_hash BYTEA NOT NULL, salt BYTEA NOT NULL, password_iterations INTEGER NOT NULL, password_hint TEXT, akey TEXT NOT NULL, private_key TEXT, public_key TEXT, totp_secret TEXT, totp_recover TEXT, security_stamp TEXT NOT NULL, equivalent_domains TEXT NOT NULL, excluded_globals TEXT NOT NULL, client_kdf_type INTEGER NOT NULL DEFAULT 0, client_kdf_iter INTEGER NOT NULL DEFAULT 100000 ); CREATE TABLE devices ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), name TEXT NOT NULL, atype INTEGER NOT NULL, push_token TEXT, refresh_token TEXT NOT NULL, twofactor_remember TEXT ); CREATE TABLE organizations ( uuid VARCHAR(40) NOT NULL PRIMARY KEY, name TEXT NOT NULL, billing_email TEXT NOT NULL ); CREATE TABLE ciphers ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, user_uuid CHAR(36) REFERENCES users (uuid), organization_uuid CHAR(36) REFERENCES organizations (uuid), atype INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, favorite BOOLEAN NOT NULL, password_history TEXT ); CREATE TABLE attachments ( id CHAR(36) NOT NULL PRIMARY KEY, cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), file_name TEXT NOT NULL, file_size INTEGER NOT NULL, akey TEXT ); CREATE TABLE folders ( uuid CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), name TEXT NOT NULL ); CREATE TABLE collections ( uuid VARCHAR(40) NOT NULL PRIMARY KEY, org_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid), name TEXT NOT NULL ); CREATE TABLE users_collections ( user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), collection_uuid CHAR(36) NOT NULL REFERENCES collections (uuid), read_only BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY (user_uuid, collection_uuid) ); CREATE TABLE users_organizations ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), org_uuid CHAR(36) NOT NULL REFERENCES organizations (uuid), access_all BOOLEAN NOT NULL, akey TEXT NOT NULL, status INTEGER NOT NULL, atype INTEGER NOT NULL, UNIQUE (user_uuid, org_uuid) ); CREATE TABLE folders_ciphers ( cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), folder_uuid CHAR(36) NOT NULL REFERENCES folders (uuid), PRIMARY KEY (cipher_uuid, folder_uuid) ); CREATE TABLE ciphers_collections ( cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid), collection_uuid CHAR(36) NOT NULL REFERENCES collections (uuid), PRIMARY KEY (cipher_uuid, collection_uuid) ); CREATE TABLE twofactor ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL REFERENCES users (uuid), atype INTEGER NOT NULL, enabled BOOLEAN NOT NULL, data TEXT NOT NULL, UNIQUE (user_uuid, atype) ); CREATE TABLE invitations ( email VARCHAR(255) NOT NULL PRIMARY KEY ); ================================================ FILE: migrations/postgresql/2019-09-16-150000_fix_attachments/down.sql ================================================ ALTER TABLE attachments ALTER COLUMN id TYPE CHAR(36); ALTER TABLE attachments ALTER COLUMN cipher_uuid TYPE CHAR(36); ALTER TABLE users ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255); ALTER TABLE devices ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE devices ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE organizations ALTER COLUMN uuid TYPE CHAR(40); ALTER TABLE ciphers ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE ciphers ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE ciphers ALTER COLUMN organization_uuid TYPE CHAR(36); ALTER TABLE folders ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE folders ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE collections ALTER COLUMN uuid TYPE CHAR(40); ALTER TABLE collections ALTER COLUMN org_uuid TYPE CHAR(40); ALTER TABLE users_collections ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE users_collections ALTER COLUMN collection_uuid TYPE CHAR(36); ALTER TABLE users_organizations ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE users_organizations ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE users_organizations ALTER COLUMN org_uuid TYPE CHAR(36); ALTER TABLE folders_ciphers ALTER COLUMN cipher_uuid TYPE CHAR(36); ALTER TABLE folders_ciphers ALTER COLUMN folder_uuid TYPE CHAR(36); ALTER TABLE ciphers_collections ALTER COLUMN cipher_uuid TYPE CHAR(36); ALTER TABLE ciphers_collections ALTER COLUMN collection_uuid TYPE CHAR(36); ALTER TABLE twofactor ALTER COLUMN uuid TYPE CHAR(36); ALTER TABLE twofactor ALTER COLUMN user_uuid TYPE CHAR(36); ALTER TABLE invitations ALTER COLUMN email TYPE VARCHAR(255); ================================================ FILE: migrations/postgresql/2019-09-16-150000_fix_attachments/up.sql ================================================ -- Switch from CHAR() types to VARCHAR() types to avoid padding issues. ALTER TABLE attachments ALTER COLUMN id TYPE TEXT; ALTER TABLE attachments ALTER COLUMN cipher_uuid TYPE VARCHAR(40); ALTER TABLE users ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE users ALTER COLUMN email TYPE TEXT; ALTER TABLE devices ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE devices ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE organizations ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE ciphers ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE ciphers ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE ciphers ALTER COLUMN organization_uuid TYPE VARCHAR(40); ALTER TABLE folders ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE folders ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE collections ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE collections ALTER COLUMN org_uuid TYPE VARCHAR(40); ALTER TABLE users_collections ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE users_collections ALTER COLUMN collection_uuid TYPE VARCHAR(40); ALTER TABLE users_organizations ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE users_organizations ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE users_organizations ALTER COLUMN org_uuid TYPE VARCHAR(40); ALTER TABLE folders_ciphers ALTER COLUMN cipher_uuid TYPE VARCHAR(40); ALTER TABLE folders_ciphers ALTER COLUMN folder_uuid TYPE VARCHAR(40); ALTER TABLE ciphers_collections ALTER COLUMN cipher_uuid TYPE VARCHAR(40); ALTER TABLE ciphers_collections ALTER COLUMN collection_uuid TYPE VARCHAR(40); ALTER TABLE twofactor ALTER COLUMN uuid TYPE VARCHAR(40); ALTER TABLE twofactor ALTER COLUMN user_uuid TYPE VARCHAR(40); ALTER TABLE invitations ALTER COLUMN email TYPE TEXT; ================================================ FILE: migrations/postgresql/2019-10-10-083032_add_column_to_twofactor/down.sql ================================================ ================================================ FILE: migrations/postgresql/2019-10-10-083032_add_column_to_twofactor/up.sql ================================================ ALTER TABLE twofactor ADD COLUMN last_used INTEGER NOT NULL DEFAULT 0; ================================================ FILE: migrations/postgresql/2019-11-17-011009_add_email_verification/down.sql ================================================ ================================================ FILE: migrations/postgresql/2019-11-17-011009_add_email_verification/up.sql ================================================ ALTER TABLE users ADD COLUMN verified_at TIMESTAMP DEFAULT NULL; ALTER TABLE users ADD COLUMN last_verifying_at TIMESTAMP DEFAULT NULL; ALTER TABLE users ADD COLUMN login_verify_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN email_new VARCHAR(255) DEFAULT NULL; ALTER TABLE users ADD COLUMN email_new_token VARCHAR(16) DEFAULT NULL; ================================================ FILE: migrations/postgresql/2020-03-13-205045_add_policy_table/down.sql ================================================ DROP TABLE org_policies; ================================================ FILE: migrations/postgresql/2020-03-13-205045_add_policy_table/up.sql ================================================ 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) ); ================================================ FILE: migrations/postgresql/2020-04-09-235005_add_cipher_delete_date/down.sql ================================================ ================================================ FILE: migrations/postgresql/2020-04-09-235005_add_cipher_delete_date/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN deleted_at TIMESTAMP; ================================================ FILE: migrations/postgresql/2020-07-01-214531_add_hide_passwords/down.sql ================================================ ================================================ FILE: migrations/postgresql/2020-07-01-214531_add_hide_passwords/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/postgresql/2020-08-02-025025_add_favorites_table/down.sql ================================================ ALTER TABLE ciphers ADD COLUMN favorite BOOLEAN NOT NULL DEFAULT FALSE; -- Transfer favorite status for user-owned ciphers. UPDATE ciphers SET favorite = TRUE WHERE EXISTS ( SELECT * FROM favorites WHERE favorites.user_uuid = ciphers.user_uuid AND favorites.cipher_uuid = ciphers.uuid ); DROP TABLE favorites; ================================================ FILE: migrations/postgresql/2020-08-02-025025_add_favorites_table/up.sql ================================================ CREATE TABLE favorites ( user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), cipher_uuid VARCHAR(40) NOT NULL REFERENCES ciphers(uuid), PRIMARY KEY (user_uuid, cipher_uuid) ); -- Transfer favorite status for user-owned ciphers. INSERT INTO favorites(user_uuid, cipher_uuid) SELECT user_uuid, uuid FROM ciphers WHERE favorite = TRUE AND user_uuid IS NOT NULL; ALTER TABLE ciphers DROP COLUMN favorite; ================================================ FILE: migrations/postgresql/2020-11-30-224000_add_user_enabled/down.sql ================================================ ================================================ FILE: migrations/postgresql/2020-11-30-224000_add_user_enabled/up.sql ================================================ ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true; ================================================ FILE: migrations/postgresql/2020-12-09-173101_add_stamp_exception/down.sql ================================================ ================================================ FILE: migrations/postgresql/2020-12-09-173101_add_stamp_exception/up.sql ================================================ ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL; ================================================ FILE: migrations/postgresql/2021-03-11-190243_add_sends/down.sql ================================================ DROP TABLE sends; ================================================ FILE: migrations/postgresql/2021-03-11-190243_add_sends/up.sql ================================================ CREATE TABLE sends ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) REFERENCES users (uuid), organization_uuid CHAR(36) REFERENCES organizations (uuid), name TEXT NOT NULL, notes TEXT, atype INTEGER NOT NULL, data TEXT NOT NULL, key TEXT NOT NULL, password_hash BYTEA, password_salt BYTEA, password_iter INTEGER, max_access_count INTEGER, access_count INTEGER NOT NULL, creation_date TIMESTAMP NOT NULL, revision_date TIMESTAMP NOT NULL, expiration_date TIMESTAMP, deletion_date TIMESTAMP NOT NULL, disabled BOOLEAN NOT NULL ); ================================================ FILE: migrations/postgresql/2021-03-15-163412_rename_send_key/down.sql ================================================ ================================================ FILE: migrations/postgresql/2021-03-15-163412_rename_send_key/up.sql ================================================ ALTER TABLE sends RENAME COLUMN key TO akey; ================================================ FILE: migrations/postgresql/2021-04-30-233251_add_reprompt/down.sql ================================================ ================================================ FILE: migrations/postgresql/2021-04-30-233251_add_reprompt/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN reprompt INTEGER; ================================================ FILE: migrations/postgresql/2021-05-11-205202_add_hide_email/down.sql ================================================ ================================================ FILE: migrations/postgresql/2021-05-11-205202_add_hide_email/up.sql ================================================ ALTER TABLE sends ADD COLUMN hide_email BOOLEAN; ================================================ FILE: migrations/postgresql/2021-07-01-203140_add_password_reset_keys/down.sql ================================================ ================================================ FILE: migrations/postgresql/2021-07-01-203140_add_password_reset_keys/up.sql ================================================ ALTER TABLE organizations ADD COLUMN private_key TEXT; ALTER TABLE organizations ADD COLUMN public_key TEXT; ================================================ FILE: migrations/postgresql/2021-08-30-193501_create_emergency_access/down.sql ================================================ DROP TABLE emergency_access; ================================================ FILE: migrations/postgresql/2021-08-30-193501_create_emergency_access/up.sql ================================================ CREATE TABLE emergency_access ( uuid CHAR(36) NOT NULL PRIMARY KEY, grantor_uuid CHAR(36) REFERENCES users (uuid), grantee_uuid CHAR(36) REFERENCES users (uuid), email VARCHAR(255), key_encrypted TEXT, atype INTEGER NOT NULL, status INTEGER NOT NULL, wait_time_days INTEGER NOT NULL, recovery_initiated_at TIMESTAMP, last_notification_at TIMESTAMP, updated_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL ); ================================================ FILE: migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/down.sql ================================================ DROP TABLE twofactor_incomplete; ================================================ FILE: migrations/postgresql/2021-10-24-164321_add_2fa_incomplete/up.sql ================================================ CREATE TABLE twofactor_incomplete ( user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid), device_uuid VARCHAR(40) NOT NULL, device_name TEXT NOT NULL, login_time TIMESTAMP NOT NULL, ip_address TEXT NOT NULL, PRIMARY KEY (user_uuid, device_uuid) ); ================================================ FILE: migrations/postgresql/2022-01-17-234911_add_api_key/down.sql ================================================ ================================================ FILE: migrations/postgresql/2022-01-17-234911_add_api_key/up.sql ================================================ ALTER TABLE users ADD COLUMN api_key TEXT; ================================================ FILE: migrations/postgresql/2022-03-02-210038_update_devices_primary_key/down.sql ================================================ ================================================ FILE: migrations/postgresql/2022-03-02-210038_update_devices_primary_key/up.sql ================================================ -- First remove the previous primary key ALTER TABLE devices DROP CONSTRAINT devices_pkey; -- Add a new combined one ALTER TABLE devices ADD PRIMARY KEY (uuid, user_uuid); ================================================ FILE: migrations/postgresql/2022-07-27-110000_add_group_support/down.sql ================================================ DROP TABLE groups; DROP TABLE groups_users; DROP TABLE collections_groups; ================================================ FILE: migrations/postgresql/2022-07-27-110000_add_group_support/up.sql ================================================ CREATE TABLE groups ( uuid CHAR(36) NOT NULL PRIMARY KEY, organizations_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid), name VARCHAR(100) NOT NULL, access_all BOOLEAN NOT NULL, external_id VARCHAR(300) NULL, creation_date TIMESTAMP NOT NULL, revision_date TIMESTAMP NOT NULL ); CREATE TABLE groups_users ( groups_uuid CHAR(36) NOT NULL REFERENCES groups (uuid), users_organizations_uuid VARCHAR(36) NOT NULL REFERENCES users_organizations (uuid), PRIMARY KEY (groups_uuid, users_organizations_uuid) ); CREATE TABLE collections_groups ( collections_uuid VARCHAR(40) NOT NULL REFERENCES collections (uuid), groups_uuid CHAR(36) NOT NULL REFERENCES groups (uuid), read_only BOOLEAN NOT NULL, hide_passwords BOOLEAN NOT NULL, PRIMARY KEY (collections_uuid, groups_uuid) ); ================================================ FILE: migrations/postgresql/2022-10-18-170602_add_events/down.sql ================================================ DROP TABLE event; ================================================ FILE: migrations/postgresql/2022-10-18-170602_add_events/up.sql ================================================ CREATE TABLE event ( uuid CHAR(36) NOT NULL PRIMARY KEY, event_type INTEGER NOT NULL, user_uuid CHAR(36), org_uuid CHAR(36), cipher_uuid CHAR(36), collection_uuid CHAR(36), group_uuid CHAR(36), org_user_uuid CHAR(36), act_user_uuid CHAR(36), device_type INTEGER, ip_address TEXT, event_date TIMESTAMP NOT NULL, policy_uuid CHAR(36), provider_uuid CHAR(36), provider_user_uuid CHAR(36), provider_org_uuid CHAR(36), UNIQUE (uuid) ); ================================================ FILE: migrations/postgresql/2023-01-06-151600_add_reset_password_support/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-01-06-151600_add_reset_password_support/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN reset_password_key TEXT; ================================================ FILE: migrations/postgresql/2023-01-11-205851_add_avatar_color/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-01-11-205851_add_avatar_color/up.sql ================================================ ALTER TABLE users ADD COLUMN avatar_color TEXT; ================================================ FILE: migrations/postgresql/2023-01-31-222222_add_argon2/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-01-31-222222_add_argon2/up.sql ================================================ ALTER TABLE users ADD COLUMN client_kdf_memory INTEGER DEFAULT NULL; ALTER TABLE users ADD COLUMN client_kdf_parallelism INTEGER DEFAULT NULL; ================================================ FILE: migrations/postgresql/2023-02-18-125735_push_uuid_table/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-02-18-125735_push_uuid_table/up.sql ================================================ ALTER TABLE devices ADD COLUMN push_uuid TEXT; ================================================ FILE: migrations/postgresql/2023-06-02-200424_create_organization_api_key/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-06-02-200424_create_organization_api_key/up.sql ================================================ CREATE TABLE organization_api_key ( uuid CHAR(36) NOT NULL, org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), atype INTEGER NOT NULL, api_key VARCHAR(255), revision_date TIMESTAMP NOT NULL, PRIMARY KEY(uuid, org_uuid) ); ALTER TABLE users ADD COLUMN external_id TEXT; ================================================ FILE: migrations/postgresql/2023-06-17-200424_create_auth_requests_table/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-06-17-200424_create_auth_requests_table/up.sql ================================================ CREATE TABLE auth_requests ( uuid CHAR(36) NOT NULL PRIMARY KEY, user_uuid CHAR(36) NOT NULL, organization_uuid CHAR(36), request_device_identifier CHAR(36) NOT NULL, device_type INTEGER NOT NULL, request_ip TEXT NOT NULL, response_device_id CHAR(36), access_code TEXT NOT NULL, public_key TEXT NOT NULL, enc_key TEXT NOT NULL, master_password_hash TEXT NOT NULL, approved BOOLEAN, creation_date TIMESTAMP NOT NULL, response_date TIMESTAMP, authentication_date TIMESTAMP, FOREIGN KEY(user_uuid) REFERENCES users(uuid), FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) ); ================================================ FILE: migrations/postgresql/2023-06-28-133700_add_collection_external_id/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-06-28-133700_add_collection_external_id/up.sql ================================================ ALTER TABLE collections ADD COLUMN external_id TEXT; ================================================ FILE: migrations/postgresql/2023-09-01-170620_update_auth_request_table/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-09-01-170620_update_auth_request_table/up.sql ================================================ ALTER TABLE auth_requests ALTER COLUMN master_password_hash DROP NOT NULL; ALTER TABLE auth_requests ALTER COLUMN enc_key DROP NOT NULL; ================================================ FILE: migrations/postgresql/2023-09-02-212336_move_user_external_id/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-09-02-212336_move_user_external_id/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN external_id TEXT; ================================================ FILE: migrations/postgresql/2023-09-10-133000_add_sso/down.sql ================================================ DROP TABLE sso_nonce; ================================================ FILE: migrations/postgresql/2023-09-10-133000_add_sso/up.sql ================================================ CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql ================================================ ALTER TABLE users_organizations DROP COLUMN invited_by_email; ================================================ FILE: migrations/postgresql/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; ================================================ FILE: migrations/postgresql/2023-10-21-221242_add_cipher_key/down.sql ================================================ ================================================ FILE: migrations/postgresql/2023-10-21-221242_add_cipher_key/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN "key" TEXT; ================================================ FILE: migrations/postgresql/2024-01-12-210182_change_attachment_size/down.sql ================================================ ================================================ FILE: migrations/postgresql/2024-01-12-210182_change_attachment_size/up.sql ================================================ ALTER TABLE attachments ALTER COLUMN file_size TYPE BIGINT, ALTER COLUMN file_size SET NOT NULL; ================================================ FILE: migrations/postgresql/2024-02-14-135953_change_time_stamp_data_type/down.sql ================================================ ================================================ FILE: migrations/postgresql/2024-02-14-135953_change_time_stamp_data_type/up.sql ================================================ ALTER TABLE twofactor ALTER COLUMN last_used TYPE BIGINT, ALTER COLUMN last_used SET NOT NULL; ================================================ FILE: migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/down.sql ================================================ DROP TABLE sso_nonce; CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2024-02-14-170000_add_state_to_sso_nonce/up.sql ================================================ DROP TABLE sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql ================================================ DROP TABLE IF EXISTS sso_users; ================================================ FILE: migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql ================================================ CREATE TABLE sso_users ( user_uuid CHAR(36) NOT NULL PRIMARY KEY, identifier TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT now(), FOREIGN KEY(user_uuid) REFERENCES users(uuid) ); ================================================ FILE: migrations/postgresql/2024-03-13-170000_sso_users_cascade/down.sql ================================================ ================================================ FILE: migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql ================================================ ALTER TABLE sso_users DROP CONSTRAINT "sso_users_user_uuid_fkey", ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; ================================================ FILE: migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql ================================================ DROP TABLE twofactor_duo_ctx; ================================================ FILE: migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql ================================================ CREATE TABLE twofactor_duo_ctx ( state VARCHAR(64) NOT NULL, user_email VARCHAR(255) NOT NULL, nonce VARCHAR(64) NOT NULL, exp BIGINT NOT NULL, PRIMARY KEY (state) ); ================================================ FILE: migrations/postgresql/2024-09-04-091351_use_device_type_for_mails/down.sql ================================================ ALTER TABLE twofactor_incomplete DROP COLUMN device_type; ================================================ FILE: migrations/postgresql/2024-09-04-091351_use_device_type_for_mails/up.sql ================================================ ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser ================================================ FILE: migrations/postgresql/2025-01-09-172300_add_manage/down.sql ================================================ ================================================ FILE: migrations/postgresql/2025-01-09-172300_add_manage/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE collections_groups ADD COLUMN manage BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/down.sql ================================================ DROP TABLE IF EXISTS sso_auth; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/postgresql/2025-08-20-120000_sso_nonce_to_auth/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_auth ( state TEXT NOT NULL PRIMARY KEY, client_challenge TEXT NOT NULL, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_response TEXT, auth_response TEXT, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); ================================================ FILE: migrations/sqlite/2018-01-14-171611_create_tables/down.sql ================================================ DROP TABLE users; DROP TABLE devices; DROP TABLE ciphers; DROP TABLE attachments; DROP TABLE folders; ================================================ FILE: migrations/sqlite/2018-01-14-171611_create_tables/up.sql ================================================ CREATE TABLE users ( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, password_iterations INTEGER NOT NULL, password_hint TEXT, key TEXT NOT NULL, private_key TEXT, public_key TEXT, totp_secret TEXT, totp_recover TEXT, security_stamp TEXT NOT NULL, equivalent_domains TEXT NOT NULL, excluded_globals TEXT NOT NULL ); CREATE TABLE devices ( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT NOT NULL REFERENCES users (uuid), name TEXT NOT NULL, type INTEGER NOT NULL, push_token TEXT, refresh_token TEXT NOT NULL ); CREATE TABLE ciphers ( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT NOT NULL REFERENCES users (uuid), folder_uuid TEXT REFERENCES folders (uuid), organization_uuid TEXT, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, favorite BOOLEAN NOT NULL ); CREATE TABLE attachments ( id TEXT NOT NULL PRIMARY KEY, cipher_uuid TEXT NOT NULL REFERENCES ciphers (uuid), file_name TEXT NOT NULL, file_size INTEGER NOT NULL ); CREATE TABLE folders ( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT NOT NULL REFERENCES users (uuid), name TEXT NOT NULL ); ================================================ FILE: migrations/sqlite/2018-02-17-205753_create_collections_and_orgs/down.sql ================================================ DROP TABLE collections; DROP TABLE organizations; DROP TABLE users_collections; DROP TABLE users_organizations; ================================================ FILE: migrations/sqlite/2018-02-17-205753_create_collections_and_orgs/up.sql ================================================ CREATE TABLE collections ( uuid TEXT NOT NULL PRIMARY KEY, org_uuid TEXT NOT NULL REFERENCES organizations (uuid), name TEXT NOT NULL ); CREATE TABLE organizations ( uuid TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, billing_email TEXT NOT NULL ); CREATE TABLE users_collections ( user_uuid TEXT NOT NULL REFERENCES users (uuid), collection_uuid TEXT NOT NULL REFERENCES collections (uuid), PRIMARY KEY (user_uuid, collection_uuid) ); CREATE TABLE users_organizations ( uuid TEXT NOT NULL PRIMARY KEY, user_uuid TEXT NOT NULL REFERENCES users (uuid), org_uuid TEXT NOT NULL REFERENCES organizations (uuid), access_all BOOLEAN NOT NULL, key TEXT NOT NULL, status INTEGER NOT NULL, type INTEGER NOT NULL, UNIQUE (user_uuid, org_uuid) ); ================================================ FILE: migrations/sqlite/2018-04-27-155151_create_users_ciphers/down.sql ================================================ ================================================ FILE: migrations/sqlite/2018-04-27-155151_create_users_ciphers/up.sql ================================================ ALTER TABLE ciphers RENAME TO oldCiphers; CREATE TABLE ciphers ( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT REFERENCES users (uuid), -- Make this optional organization_uuid TEXT REFERENCES organizations (uuid), -- Add reference to orgs table -- Remove folder_uuid type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, favorite BOOLEAN NOT NULL ); CREATE TABLE folders_ciphers ( cipher_uuid TEXT NOT NULL REFERENCES ciphers (uuid), folder_uuid TEXT NOT NULL REFERENCES folders (uuid), PRIMARY KEY (cipher_uuid, folder_uuid) ); INSERT INTO ciphers (uuid, created_at, updated_at, user_uuid, organization_uuid, type, name, notes, fields, data, favorite) SELECT uuid, created_at, updated_at, user_uuid, organization_uuid, type, name, notes, fields, data, favorite FROM oldCiphers; INSERT INTO folders_ciphers (cipher_uuid, folder_uuid) SELECT uuid, folder_uuid FROM oldCiphers WHERE folder_uuid IS NOT NULL; DROP TABLE oldCiphers; ALTER TABLE users_collections ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT 0; -- False ================================================ FILE: migrations/sqlite/2018-05-08-161616_create_collection_cipher_map/down.sql ================================================ DROP TABLE ciphers_collections; ================================================ FILE: migrations/sqlite/2018-05-08-161616_create_collection_cipher_map/up.sql ================================================ CREATE TABLE ciphers_collections ( cipher_uuid TEXT NOT NULL REFERENCES ciphers (uuid), collection_uuid TEXT NOT NULL REFERENCES collections (uuid), PRIMARY KEY (cipher_uuid, collection_uuid) ); ================================================ FILE: migrations/sqlite/2018-05-25-232323_update_attachments_reference/down.sql ================================================ ================================================ FILE: migrations/sqlite/2018-05-25-232323_update_attachments_reference/up.sql ================================================ ALTER TABLE attachments RENAME TO oldAttachments; CREATE TABLE attachments ( id TEXT NOT NULL PRIMARY KEY, cipher_uuid TEXT NOT NULL REFERENCES ciphers (uuid), file_name TEXT NOT NULL, file_size INTEGER NOT NULL ); INSERT INTO attachments (id, cipher_uuid, file_name, file_size) SELECT id, cipher_uuid, file_name, file_size FROM oldAttachments; DROP TABLE oldAttachments; ================================================ FILE: migrations/sqlite/2018-06-01-112529_update_devices_twofactor_remember/down.sql ================================================ -- This file should undo anything in `up.sql` ================================================ FILE: migrations/sqlite/2018-06-01-112529_update_devices_twofactor_remember/up.sql ================================================ ALTER TABLE devices ADD COLUMN twofactor_remember TEXT; ================================================ FILE: migrations/sqlite/2018-07-11-181453_create_u2f_twofactor/down.sql ================================================ UPDATE users SET totp_secret = ( SELECT twofactor.data FROM twofactor WHERE twofactor.type = 0 AND twofactor.user_uuid = users.uuid ); DROP TABLE twofactor; ================================================ FILE: migrations/sqlite/2018-07-11-181453_create_u2f_twofactor/up.sql ================================================ CREATE TABLE twofactor ( uuid TEXT NOT NULL PRIMARY KEY, user_uuid TEXT NOT NULL REFERENCES users (uuid), type INTEGER NOT NULL, enabled BOOLEAN NOT NULL, data TEXT NOT NULL, UNIQUE (user_uuid, type) ); INSERT INTO twofactor (uuid, user_uuid, type, enabled, data) SELECT lower(hex(randomblob(16))) , uuid, 0, 1, u.totp_secret FROM users u where u.totp_secret IS NOT NULL; UPDATE users SET totp_secret = NULL; -- Instead of recreating the table, just leave the columns empty ================================================ FILE: migrations/sqlite/2018-08-27-172114_update_ciphers/down.sql ================================================ ================================================ FILE: migrations/sqlite/2018-08-27-172114_update_ciphers/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN password_history TEXT; ================================================ FILE: migrations/sqlite/2018-09-10-111213_add_invites/down.sql ================================================ DROP TABLE invitations; ================================================ FILE: migrations/sqlite/2018-09-10-111213_add_invites/up.sql ================================================ CREATE TABLE invitations ( email TEXT NOT NULL PRIMARY KEY ); ================================================ FILE: migrations/sqlite/2018-09-19-144557_add_kdf_columns/down.sql ================================================ ================================================ FILE: migrations/sqlite/2018-09-19-144557_add_kdf_columns/up.sql ================================================ ALTER TABLE users ADD COLUMN client_kdf_type INTEGER NOT NULL DEFAULT 0; -- PBKDF2 ALTER TABLE users ADD COLUMN client_kdf_iter INTEGER NOT NULL DEFAULT 100000; ================================================ FILE: migrations/sqlite/2018-11-27-152651_add_att_key_columns/down.sql ================================================ ================================================ FILE: migrations/sqlite/2018-11-27-152651_add_att_key_columns/up.sql ================================================ ALTER TABLE attachments ADD COLUMN key TEXT; ================================================ FILE: migrations/sqlite/2019-05-26-216651_rename_key_and_type_columns/down.sql ================================================ ALTER TABLE attachments RENAME COLUMN akey TO key; ALTER TABLE ciphers RENAME COLUMN atype TO type; ALTER TABLE devices RENAME COLUMN atype TO type; ALTER TABLE twofactor RENAME COLUMN atype TO type; ALTER TABLE users RENAME COLUMN akey TO key; ALTER TABLE users_organizations RENAME COLUMN akey TO key; ALTER TABLE users_organizations RENAME COLUMN atype TO type; ================================================ FILE: migrations/sqlite/2019-05-26-216651_rename_key_and_type_columns/up.sql ================================================ ALTER TABLE attachments RENAME COLUMN key TO akey; ALTER TABLE ciphers RENAME COLUMN type TO atype; ALTER TABLE devices RENAME COLUMN type TO atype; ALTER TABLE twofactor RENAME COLUMN type TO atype; ALTER TABLE users RENAME COLUMN key TO akey; ALTER TABLE users_organizations RENAME COLUMN key TO akey; ALTER TABLE users_organizations RENAME COLUMN type TO atype; ================================================ FILE: migrations/sqlite/2019-10-10-083032_add_column_to_twofactor/down.sql ================================================ ================================================ FILE: migrations/sqlite/2019-10-10-083032_add_column_to_twofactor/up.sql ================================================ ALTER TABLE twofactor ADD COLUMN last_used INTEGER NOT NULL DEFAULT 0; ================================================ FILE: migrations/sqlite/2019-11-17-011009_add_email_verification/down.sql ================================================ ================================================ FILE: migrations/sqlite/2019-11-17-011009_add_email_verification/up.sql ================================================ ALTER TABLE users ADD COLUMN verified_at DATETIME DEFAULT NULL; ALTER TABLE users ADD COLUMN last_verifying_at DATETIME DEFAULT NULL; ALTER TABLE users ADD COLUMN login_verify_count INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN email_new TEXT DEFAULT NULL; ALTER TABLE users ADD COLUMN email_new_token TEXT DEFAULT NULL; ================================================ FILE: migrations/sqlite/2020-03-13-205045_add_policy_table/down.sql ================================================ DROP TABLE org_policies; ================================================ FILE: migrations/sqlite/2020-03-13-205045_add_policy_table/up.sql ================================================ 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) ); ================================================ FILE: migrations/sqlite/2020-04-09-235005_add_cipher_delete_date/down.sql ================================================ ================================================ FILE: migrations/sqlite/2020-04-09-235005_add_cipher_delete_date/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN deleted_at DATETIME; ================================================ FILE: migrations/sqlite/2020-07-01-214531_add_hide_passwords/down.sql ================================================ ================================================ FILE: migrations/sqlite/2020-07-01-214531_add_hide_passwords/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN hide_passwords BOOLEAN NOT NULL DEFAULT 0; -- FALSE ================================================ FILE: migrations/sqlite/2020-08-02-025025_add_favorites_table/down.sql ================================================ ALTER TABLE ciphers ADD COLUMN favorite BOOLEAN NOT NULL DEFAULT 0; -- FALSE -- Transfer favorite status for user-owned ciphers. UPDATE ciphers SET favorite = 1 WHERE EXISTS ( SELECT * FROM favorites WHERE favorites.user_uuid = ciphers.user_uuid AND favorites.cipher_uuid = ciphers.uuid ); DROP TABLE favorites; ================================================ FILE: migrations/sqlite/2020-08-02-025025_add_favorites_table/up.sql ================================================ CREATE TABLE favorites ( user_uuid TEXT NOT NULL REFERENCES users(uuid), cipher_uuid TEXT NOT NULL REFERENCES ciphers(uuid), PRIMARY KEY (user_uuid, cipher_uuid) ); -- Transfer favorite status for user-owned ciphers. INSERT INTO favorites(user_uuid, cipher_uuid) SELECT user_uuid, uuid FROM ciphers WHERE favorite = 1 AND user_uuid IS NOT NULL; -- Drop the `favorite` column from the `ciphers` table, using the 12-step -- procedure from . -- Note that some steps aren't applicable and are omitted. -- 1. If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF. -- -- Diesel runs each migration in its own transaction. `PRAGMA foreign_keys` -- is a no-op within a transaction, so this step must be done outside of this -- file, before starting the Diesel migrations. -- 2. Start a transaction. -- -- Diesel already runs each migration in its own transaction. -- 4. Use CREATE TABLE to construct a new table "new_X" that is in the -- desired revised format of table X. Make sure that the name "new_X" does -- not collide with any existing table name, of course. CREATE TABLE new_ciphers( uuid TEXT NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT REFERENCES users(uuid), organization_uuid TEXT REFERENCES organizations(uuid), atype INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, fields TEXT, data TEXT NOT NULL, password_history TEXT, deleted_at DATETIME ); -- 5. Transfer content from X into new_X using a statement like: -- INSERT INTO new_X SELECT ... FROM X. INSERT INTO new_ciphers(uuid, created_at, updated_at, user_uuid, organization_uuid, atype, name, notes, fields, data, password_history, deleted_at) SELECT uuid, created_at, updated_at, user_uuid, organization_uuid, atype, name, notes, fields, data, password_history, deleted_at FROM ciphers; -- 6. Drop the old table X: DROP TABLE X. DROP TABLE ciphers; -- 7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X. ALTER TABLE new_ciphers RENAME TO ciphers; -- 11. Commit the transaction started in step 2. -- 12. If foreign keys constraints were originally enabled, reenable them now. -- -- `PRAGMA foreign_keys` is scoped to a database connection, and Diesel -- migrations are run in a separate database connection that is closed once -- the migrations finish. ================================================ FILE: migrations/sqlite/2020-11-30-224000_add_user_enabled/down.sql ================================================ ================================================ FILE: migrations/sqlite/2020-11-30-224000_add_user_enabled/up.sql ================================================ ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1; ================================================ FILE: migrations/sqlite/2020-12-09-173101_add_stamp_exception/down.sql ================================================ ================================================ FILE: migrations/sqlite/2020-12-09-173101_add_stamp_exception/up.sql ================================================ ALTER TABLE users ADD COLUMN stamp_exception TEXT DEFAULT NULL; ================================================ FILE: migrations/sqlite/2021-03-11-190243_add_sends/down.sql ================================================ DROP TABLE sends; ================================================ FILE: migrations/sqlite/2021-03-11-190243_add_sends/up.sql ================================================ CREATE TABLE sends ( uuid TEXT NOT NULL PRIMARY KEY, user_uuid TEXT REFERENCES users (uuid), organization_uuid TEXT REFERENCES organizations (uuid), name TEXT NOT NULL, notes TEXT, atype INTEGER NOT NULL, data TEXT NOT NULL, key TEXT NOT NULL, password_hash BLOB, password_salt BLOB, password_iter INTEGER, max_access_count INTEGER, access_count INTEGER NOT NULL, creation_date DATETIME NOT NULL, revision_date DATETIME NOT NULL, expiration_date DATETIME, deletion_date DATETIME NOT NULL, disabled BOOLEAN NOT NULL ); ================================================ FILE: migrations/sqlite/2021-03-15-163412_rename_send_key/down.sql ================================================ ================================================ FILE: migrations/sqlite/2021-03-15-163412_rename_send_key/up.sql ================================================ ALTER TABLE sends RENAME COLUMN key TO akey; ================================================ FILE: migrations/sqlite/2021-04-30-233251_add_reprompt/down.sql ================================================ ================================================ FILE: migrations/sqlite/2021-04-30-233251_add_reprompt/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN reprompt INTEGER; ================================================ FILE: migrations/sqlite/2021-05-11-205202_add_hide_email/down.sql ================================================ ================================================ FILE: migrations/sqlite/2021-05-11-205202_add_hide_email/up.sql ================================================ ALTER TABLE sends ADD COLUMN hide_email BOOLEAN; ================================================ FILE: migrations/sqlite/2021-07-01-203140_add_password_reset_keys/down.sql ================================================ ================================================ FILE: migrations/sqlite/2021-07-01-203140_add_password_reset_keys/up.sql ================================================ ALTER TABLE organizations ADD COLUMN private_key TEXT; ALTER TABLE organizations ADD COLUMN public_key TEXT; ================================================ FILE: migrations/sqlite/2021-08-30-193501_create_emergency_access/down.sql ================================================ DROP TABLE emergency_access; ================================================ FILE: migrations/sqlite/2021-08-30-193501_create_emergency_access/up.sql ================================================ CREATE TABLE emergency_access ( uuid TEXT NOT NULL PRIMARY KEY, grantor_uuid TEXT REFERENCES users (uuid), grantee_uuid TEXT REFERENCES users (uuid), email TEXT, key_encrypted TEXT, atype INTEGER NOT NULL, status INTEGER NOT NULL, wait_time_days INTEGER NOT NULL, recovery_initiated_at DATETIME, last_notification_at DATETIME, updated_at DATETIME NOT NULL, created_at DATETIME NOT NULL ); ================================================ FILE: migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/down.sql ================================================ DROP TABLE twofactor_incomplete; ================================================ FILE: migrations/sqlite/2021-10-24-164321_add_2fa_incomplete/up.sql ================================================ CREATE TABLE twofactor_incomplete ( user_uuid TEXT NOT NULL REFERENCES users(uuid), device_uuid TEXT NOT NULL, device_name TEXT NOT NULL, login_time DATETIME NOT NULL, ip_address TEXT NOT NULL, PRIMARY KEY (user_uuid, device_uuid) ); ================================================ FILE: migrations/sqlite/2022-01-17-234911_add_api_key/down.sql ================================================ ================================================ FILE: migrations/sqlite/2022-01-17-234911_add_api_key/up.sql ================================================ ALTER TABLE users ADD COLUMN api_key TEXT; ================================================ FILE: migrations/sqlite/2022-03-02-210038_update_devices_primary_key/down.sql ================================================ ================================================ FILE: migrations/sqlite/2022-03-02-210038_update_devices_primary_key/up.sql ================================================ -- Create new devices table with primary keys on both uuid and user_uuid CREATE TABLE devices_new ( uuid TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, user_uuid TEXT NOT NULL, name TEXT NOT NULL, atype INTEGER NOT NULL, push_token TEXT, refresh_token TEXT NOT NULL, twofactor_remember TEXT, PRIMARY KEY(uuid, user_uuid), FOREIGN KEY(user_uuid) REFERENCES users(uuid) ); -- Transfer current data to new table INSERT INTO devices_new SELECT * FROM devices; -- Drop the old table DROP TABLE devices; -- Rename the new table to the original name ALTER TABLE devices_new RENAME TO devices; ================================================ FILE: migrations/sqlite/2022-07-27-110000_add_group_support/down.sql ================================================ DROP TABLE groups; DROP TABLE groups_users; DROP TABLE collections_groups; ================================================ FILE: migrations/sqlite/2022-07-27-110000_add_group_support/up.sql ================================================ CREATE TABLE groups ( uuid TEXT NOT NULL PRIMARY KEY, organizations_uuid TEXT NOT NULL REFERENCES organizations (uuid), name TEXT NOT NULL, access_all BOOLEAN NOT NULL, external_id TEXT NULL, creation_date TIMESTAMP NOT NULL, revision_date TIMESTAMP NOT NULL ); CREATE TABLE groups_users ( groups_uuid TEXT NOT NULL REFERENCES groups (uuid), users_organizations_uuid TEXT NOT NULL REFERENCES users_organizations (uuid), UNIQUE (groups_uuid, users_organizations_uuid) ); CREATE TABLE collections_groups ( collections_uuid TEXT NOT NULL REFERENCES collections (uuid), groups_uuid TEXT NOT NULL REFERENCES groups (uuid), read_only BOOLEAN NOT NULL, hide_passwords BOOLEAN NOT NULL, UNIQUE (collections_uuid, groups_uuid) ); ================================================ FILE: migrations/sqlite/2022-10-18-170602_add_events/down.sql ================================================ DROP TABLE event; ================================================ FILE: migrations/sqlite/2022-10-18-170602_add_events/up.sql ================================================ CREATE TABLE event ( uuid TEXT NOT NULL PRIMARY KEY, event_type INTEGER NOT NULL, user_uuid TEXT, org_uuid TEXT, cipher_uuid TEXT, collection_uuid TEXT, group_uuid TEXT, org_user_uuid TEXT, act_user_uuid TEXT, device_type INTEGER, ip_address TEXT, event_date DATETIME NOT NULL, policy_uuid TEXT, provider_uuid TEXT, provider_user_uuid TEXT, provider_org_uuid TEXT, UNIQUE (uuid) ); ================================================ FILE: migrations/sqlite/2023-01-06-151600_add_reset_password_support/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-01-06-151600_add_reset_password_support/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN reset_password_key TEXT; ================================================ FILE: migrations/sqlite/2023-01-11-205851_add_avatar_color/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-01-11-205851_add_avatar_color/up.sql ================================================ ALTER TABLE users ADD COLUMN avatar_color TEXT; ================================================ FILE: migrations/sqlite/2023-01-31-222222_add_argon2/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-01-31-222222_add_argon2/up.sql ================================================ ALTER TABLE users ADD COLUMN client_kdf_memory INTEGER DEFAULT NULL; ALTER TABLE users ADD COLUMN client_kdf_parallelism INTEGER DEFAULT NULL; ================================================ FILE: migrations/sqlite/2023-02-18-125735_push_uuid_table/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-02-18-125735_push_uuid_table/up.sql ================================================ ALTER TABLE devices ADD COLUMN push_uuid TEXT; ================================================ FILE: migrations/sqlite/2023-06-02-200424_create_organization_api_key/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-06-02-200424_create_organization_api_key/up.sql ================================================ CREATE TABLE organization_api_key ( uuid TEXT NOT NULL, org_uuid TEXT NOT NULL, atype INTEGER NOT NULL, api_key TEXT NOT NULL, revision_date DATETIME NOT NULL, PRIMARY KEY(uuid, org_uuid), FOREIGN KEY(org_uuid) REFERENCES organizations(uuid) ); ALTER TABLE users ADD COLUMN external_id TEXT; ================================================ FILE: migrations/sqlite/2023-06-17-200424_create_auth_requests_table/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-06-17-200424_create_auth_requests_table/up.sql ================================================ CREATE TABLE auth_requests ( uuid TEXT NOT NULL PRIMARY KEY, user_uuid TEXT NOT NULL, organization_uuid TEXT, request_device_identifier TEXT NOT NULL, device_type INTEGER NOT NULL, request_ip TEXT NOT NULL, response_device_id TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, enc_key TEXT NOT NULL, master_password_hash TEXT NOT NULL, approved BOOLEAN, creation_date DATETIME NOT NULL, response_date DATETIME, authentication_date DATETIME, FOREIGN KEY(user_uuid) REFERENCES users(uuid), FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) ); ================================================ FILE: migrations/sqlite/2023-06-28-133700_add_collection_external_id/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-06-28-133700_add_collection_external_id/up.sql ================================================ ALTER TABLE collections ADD COLUMN external_id TEXT; ================================================ FILE: migrations/sqlite/2023-09-01-170620_update_auth_request_table/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-09-01-170620_update_auth_request_table/up.sql ================================================ -- Create new auth_requests table with master_password_hash as nullable column CREATE TABLE auth_requests_new ( uuid TEXT NOT NULL PRIMARY KEY, user_uuid TEXT NOT NULL, organization_uuid TEXT, request_device_identifier TEXT NOT NULL, device_type INTEGER NOT NULL, request_ip TEXT NOT NULL, response_device_id TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, enc_key TEXT, master_password_hash TEXT, approved BOOLEAN, creation_date DATETIME NOT NULL, response_date DATETIME, authentication_date DATETIME, FOREIGN KEY (user_uuid) REFERENCES users (uuid), FOREIGN KEY (organization_uuid) REFERENCES organizations (uuid) ); -- Transfer current data to new table INSERT INTO auth_requests_new SELECT * FROM auth_requests; -- Drop the old table DROP TABLE auth_requests; -- Rename the new table to the original name ALTER TABLE auth_requests_new RENAME TO auth_requests; ================================================ FILE: migrations/sqlite/2023-09-02-212336_move_user_external_id/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-09-02-212336_move_user_external_id/up.sql ================================================ -- Add the external_id to the users_organizations table ALTER TABLE "users_organizations" ADD COLUMN "external_id" TEXT; ================================================ FILE: migrations/sqlite/2023-09-10-133000_add_sso/down.sql ================================================ DROP TABLE sso_nonce; ================================================ FILE: migrations/sqlite/2023-09-10-133000_add_sso/up.sql ================================================ CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/down.sql ================================================ ALTER TABLE users_organizations DROP COLUMN invited_by_email; ================================================ FILE: migrations/sqlite/2023-09-14-133000_add_users_organizations_invited_by_email/up.sql ================================================ ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; ================================================ FILE: migrations/sqlite/2023-10-21-221242_add_cipher_key/down.sql ================================================ ================================================ FILE: migrations/sqlite/2023-10-21-221242_add_cipher_key/up.sql ================================================ ALTER TABLE ciphers ADD COLUMN "key" TEXT; ================================================ FILE: migrations/sqlite/2024-01-12-210182_change_attachment_size/down.sql ================================================ ================================================ FILE: migrations/sqlite/2024-01-12-210182_change_attachment_size/up.sql ================================================ -- Integer size in SQLite is already i64, so we don't need to do anything ================================================ FILE: migrations/sqlite/2024-02-14-140000_change_time_stamp_data_type/down.sql ================================================ ================================================ FILE: migrations/sqlite/2024-02-14-140000_change_time_stamp_data_type/up.sql ================================================ -- Integer size in SQLite is already i64, so we don't need to do anything ================================================ FILE: migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/down.sql ================================================ DROP TABLE sso_nonce; CREATE TABLE sso_nonce ( nonce CHAR(36) NOT NULL PRIMARY KEY, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2024-02-14-170000_add_state_to_sso_nonce/up.sql ================================================ DROP TABLE sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/down.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql ================================================ DROP TABLE IF EXISTS sso_users; ================================================ FILE: migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql ================================================ CREATE TABLE sso_users ( user_uuid CHAR(36) NOT NULL PRIMARY KEY, identifier TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_uuid) REFERENCES users(uuid) ); ================================================ FILE: migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql ================================================ ================================================ FILE: migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql ================================================ DROP TABLE IF EXISTS sso_users; CREATE TABLE sso_users ( user_uuid CHAR(36) NOT NULL PRIMARY KEY, identifier TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE ); ================================================ FILE: migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql ================================================ DROP TABLE twofactor_duo_ctx; ================================================ FILE: migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql ================================================ CREATE TABLE twofactor_duo_ctx ( state TEXT NOT NULL, user_email TEXT NOT NULL, nonce TEXT NOT NULL, exp INTEGER NOT NULL, PRIMARY KEY (state) ); ================================================ FILE: migrations/sqlite/2024-09-04-091351_use_device_type_for_mails/down.sql ================================================ ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`; ================================================ FILE: migrations/sqlite/2024-09-04-091351_use_device_type_for_mails/up.sql ================================================ ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser ================================================ FILE: migrations/sqlite/2025-01-09-172300_add_manage/down.sql ================================================ ================================================ FILE: migrations/sqlite/2025-01-09-172300_add_manage/up.sql ================================================ ALTER TABLE users_collections ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE ALTER TABLE collections_groups ADD COLUMN manage BOOLEAN NOT NULL DEFAULT 0; -- FALSE ================================================ FILE: migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/down.sql ================================================ DROP TABLE IF EXISTS sso_auth; CREATE TABLE sso_nonce ( state TEXT NOT NULL PRIMARY KEY, nonce TEXT NOT NULL, verifier TEXT, redirect_uri TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: migrations/sqlite/2025-08-20-120000_sso_nonce_to_auth/up.sql ================================================ DROP TABLE IF EXISTS sso_nonce; CREATE TABLE sso_auth ( state TEXT NOT NULL PRIMARY KEY, client_challenge TEXT NOT NULL, nonce TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_response TEXT, auth_response TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: playwright/.gitignore ================================================ logs node_modules/ /test-results/ /playwright-report/ /playwright/.cache/ temp ================================================ FILE: playwright/README.md ================================================ # Integration tests This allows running integration tests using [Playwright](https://playwright.dev/). It uses its own `test.env` with different ports to not collide with a running dev instance. ## Install This relies on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in containers. ### Running Playwright outside docker It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change. You will additionally need `nodejs` then run: ```bash npm ci --ignore-scripts npx playwright install-deps npx playwright install firefox ``` ## Usage To run all the tests: ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright ``` To force a rebuild of the Playwright image: ```bash DOCKER_BUILDKIT=1 docker compose --env-file test.env build Playwright ``` To access the UI to easily run test individually and debug if needed (this will not work in docker): ```bash npx playwright test --ui ``` ### DB Projects are configured to allow to run tests only on specific database. You can use: ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mariadb DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=mysql DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=postgres DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite ``` ### SSO To run the SSO tests: ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project sso-sqlite ``` ### Keep services running If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): ```bash PW_KEEP_SERVICE_RUNNNING=true npx playwright test ``` ### Running specific tests To run a whole file you can : ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login ``` To run only a specifc test (It might fail if it has dependency): ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite tests/login.spec.ts:16 ``` ## Writing scenario When creating new scenario use the recorder to more easily identify elements (in general try to rely on visible hint to identify elements and not hidden IDs). This does not start the server, you will need to start it manually. ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden npx playwright codegen "http://127.0.0.1:8003" ``` ## Override web-vault It is possible to change the `web-vault` used by referencing a different `vw_web_builds` commit. Simplest is to set and uncomment `PW_VW_REPO_URL` and `PW_VW_COMMIT_HASH` in the `test.env`. Ensure that the image is built with: ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env build Vaultwarden ``` You can check the result running: ```bash DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden ``` Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`. # OpenID Connect test setup Additionally this `docker-compose` template allows to run locally Vaultwarden, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC. ## Setup This rely on `docker` and the `compose` [plugin](https://docs.docker.com/compose/install/). First create a copy of `.env.template` as `.env` (This is done to prevent committing your custom settings, Ex `SMTP_`). ## Usage Then start the stack (the `profile` is required to run `Vaultwarden`) : ```bash > docker compose --profile vaultwarden --env-file .env up .... keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master keycloakSetup_1 | Created new realm with id 'test' keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e oidc_keycloakSetup_1 exited with code 0 ``` Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user (It is normal for this container to stop once the configuration is done). Then you can access : - `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. - `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` - `Maildev` on http://0.0.0.0:1080 To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible. To use your computer external ip (for example when testing with a phone) you will have to configure `KC_HTTP_HOST` and `DOMAIN`. ## Running only Keycloak You can run just `Keycloak` with `--profile keycloak`: ```bash > docker compose --profile keycloak --env-file .env up ``` When running with a local Vaultwarden, you can use a front-end build from [dani-garcia/bw_web_builds](https://github.com/dani-garcia/bw_web_builds/releases). ## Rebuilding the Vaultwarden To force rebuilding the Vaultwarden image you can run ```bash docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild Vaultwarden ``` ## Configuration All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template). The content of the file will be loaded as environment variables in all containers. - `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). - All `Vaultwarden` configuration can be set (EX: `SMTP_*`) ## Cleanup Use `docker compose --profile vaultwarden down`. ================================================ FILE: playwright/compose/keycloak/Dockerfile ================================================ FROM docker.io/library/debian:trixie-slim ARG KEYCLOAK_VERSION ENV DEBIAN_FRONTEND=noninteractive SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update && apt-get install -y ca-certificates curl jq openjdk-21-jdk-headless wget WORKDIR / RUN wget -c https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz -O - | tar -xz \ && mkdir -p /opt/keycloak \ && mv /keycloak-${KEYCLOAK_VERSION}/bin /opt/keycloak/bin \ && rm -rf /keycloak-${KEYCLOAK_VERSION} COPY setup.sh /setup.sh CMD "/setup.sh" ================================================ FILE: playwright/compose/keycloak/setup.sh ================================================ #!/bin/bash export PATH=/opt/keycloak/bin:$PATH STATUS_CODE=0 while [[ "$STATUS_CODE" != "404" ]] ; do echo "Will retry in 2 seconds" sleep 2 STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DUMMY_AUTHORITY") if [[ "$STATUS_CODE" = "200" ]]; then echo "Setup should already be done. Will not run." exit 0 fi done set -e kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n # Dummy realm to mark end of setup kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600" # TO DEBUG uncomment the following line to keep the setup container running # sleep 3600 # THEN in another terminal: # docker exec -it keycloakSetup-dev /bin/bash # export PATH=$PATH:/opt/keycloak/bin # kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli # ENJOY # Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html ================================================ FILE: playwright/compose/playwright/Dockerfile ================================================ FROM docker.io/library/debian:trixie-slim SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y ca-certificates curl \ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ && chmod a+r /etc/apt/keyrings/docker.asc \ && echo "deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" | tee /etc/apt/sources.list.d/docker.list \ && apt-get update \ && apt-get install -y --no-install-recommends \ containerd.io \ docker-buildx-plugin \ docker-ce \ docker-ce-cli \ docker-compose-plugin \ git \ libmariadb-dev-compat \ libpq5 \ nodejs \ npm \ openssl \ && rm -rf /var/lib/apt/lists/* RUN mkdir /playwright WORKDIR /playwright COPY package.json package-lock.json . RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox COPY docker-compose.yml test.env ./ COPY compose ./compose COPY *.ts test.env ./ COPY tests ./tests ENTRYPOINT ["/usr/bin/npx", "playwright"] CMD ["test"] ================================================ FILE: playwright/compose/warden/Dockerfile ================================================ FROM playwright_oidc_vaultwarden_prebuilt AS prebuilt FROM node:22-trixie AS build ARG REPO_URL ARG COMMIT_HASH ENV REPO_URL=$REPO_URL ENV COMMIT_HASH=$COMMIT_HASH COPY --from=prebuilt /web-vault /web-vault COPY build.sh /build.sh RUN /build.sh ######################## RUNTIME IMAGE ######################## FROM docker.io/library/debian:trixie-slim ENV DEBIAN_FRONTEND=noninteractive # Create data folder and Install needed libraries RUN mkdir /data && \ apt-get update && apt-get install -y \ --no-install-recommends \ ca-certificates \ curl \ libmariadb-dev \ libpq5 \ openssl && \ rm -rf /var/lib/apt/lists/* # Copies the files from the context (Rocket.toml file and web-vault) # and the binary from the "build" stage to the current stage WORKDIR / COPY --from=prebuilt /start.sh . COPY --from=prebuilt /vaultwarden . COPY --from=build /web-vault ./web-vault ENTRYPOINT ["/start.sh"] ================================================ FILE: playwright/compose/warden/build.sh ================================================ #!/bin/bash echo $REPO_URL echo $COMMIT_HASH if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then rm -rf /web-vault mkdir -p vw_web_builds; cd vw_web_builds; git -c init.defaultBranch=main init git remote add origin "$REPO_URL" git fetch --depth 1 origin "$COMMIT_HASH" git -c advice.detachedHead=false checkout FETCH_HEAD npm ci --ignore-scripts cd apps/web npm run dist:oss:selfhost printf '{"version":"%s"}' "$COMMIT_HASH" > build/vw-version.json mv build /web-vault fi ================================================ FILE: playwright/docker-compose.yml ================================================ services: VaultwardenPrebuild: profiles: ["playwright", "vaultwarden"] container_name: playwright_oidc_vaultwarden_prebuilt image: playwright_oidc_vaultwarden_prebuilt build: context: .. dockerfile: Dockerfile entrypoint: /bin/bash restart: "no" Vaultwarden: profiles: ["playwright", "vaultwarden"] container_name: playwright_oidc_vaultwarden-${ENV:-dev} image: playwright_oidc_vaultwarden-${ENV:-dev} network_mode: "host" build: context: compose/warden dockerfile: Dockerfile args: REPO_URL: ${PW_VW_REPO_URL:-} COMMIT_HASH: ${PW_VW_COMMIT_HASH:-} env_file: ${DC_ENV_FILE:-.env} environment: - ADMIN_TOKEN - DATABASE_URL - I_REALLY_WANT_VOLATILE_STORAGE - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST - SMTP_HOST - SMTP_FROM - SMTP_DEBUG - SSO_DEBUG_TOKENS - SSO_ENABLED - SSO_FRONTEND - SSO_ONLY - SSO_SCOPES restart: "no" depends_on: - VaultwardenPrebuild Playwright: profiles: ["playwright"] container_name: playwright_oidc_playwright image: playwright_oidc_playwright network_mode: "host" build: context: . dockerfile: compose/playwright/Dockerfile environment: - PW_WV_REPO_URL - PW_WV_COMMIT_HASH restart: "no" volumes: - /var/run/docker.sock:/var/run/docker.sock - ..:/project Mariadb: profiles: ["playwright"] container_name: playwright_mariadb image: mariadb:11.2.4 env_file: test.env healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] start_period: 10s interval: 10s ports: - ${MARIADB_PORT}:3306 Mysql: profiles: ["playwright"] container_name: playwright_mysql image: mysql:8.4.1 env_file: test.env healthcheck: test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] start_period: 10s interval: 10s ports: - ${MYSQL_PORT}:3306 Postgres: profiles: ["playwright"] container_name: playwright_postgres image: postgres:16.3 env_file: test.env healthcheck: test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] start_period: 20s interval: 30s ports: - ${POSTGRES_PORT}:5432 Maildev: profiles: ["vaultwarden", "maildev"] container_name: maildev image: timshel/maildev:3.0.4 ports: - ${SMTP_PORT}:1025 - 1080:1080 Keycloak: profiles: ["keycloak", "vaultwarden"] container_name: keycloak-${ENV:-dev} image: quay.io/keycloak/keycloak:26.3.4 network_mode: "host" command: - start-dev env_file: ${DC_ENV_FILE:-.env} KeycloakSetup: profiles: ["keycloak", "vaultwarden"] container_name: keycloakSetup-${ENV:-dev} image: keycloak_setup-${ENV:-dev} build: context: compose/keycloak dockerfile: Dockerfile args: KEYCLOAK_VERSION: 26.3.4 network_mode: "host" depends_on: - Keycloak restart: "no" env_file: ${DC_ENV_FILE:-.env} ================================================ FILE: playwright/global-setup.ts ================================================ import { firefox, type FullConfig } from '@playwright/test'; import { execSync } from 'node:child_process'; import fs from 'fs'; const utils = require('./global-utils'); utils.loadEnv(); async function globalSetup(config: FullConfig) { // Are we running in docker and the project is mounted ? const path = (fs.existsSync("/project/playwright/playwright.config.ts") ? "/project/playwright" : "."); execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build VaultwardenPrebuild`, { env: { ...process.env }, stdio: "inherit" }); execSync(`docker compose --project-directory ${path} --profile playwright --env-file test.env build Vaultwarden`, { env: { ...process.env }, stdio: "inherit" }); } export default globalSetup; ================================================ FILE: playwright/global-utils.ts ================================================ import { expect, type Browser, type TestInfo } from '@playwright/test'; import { EventEmitter } from "events"; import { type Mail, MailServer } from 'maildev'; import { execSync } from 'node:child_process'; import dotenv from 'dotenv'; import dotenvExpand from 'dotenv-expand'; const fs = require("fs"); const { spawn } = require('node:child_process'); export function loadEnv(){ var myEnv = dotenv.config({ path: 'test.env', quiet: true }); dotenvExpand.expand(myEnv); return { user1: { email: process.env.TEST_USER_MAIL, name: process.env.TEST_USER, password: process.env.TEST_USER_PASSWORD, }, user2: { email: process.env.TEST_USER2_MAIL, name: process.env.TEST_USER2, password: process.env.TEST_USER2_PASSWORD, }, user3: { email: process.env.TEST_USER3_MAIL, name: process.env.TEST_USER3, password: process.env.TEST_USER3_PASSWORD, }, } } export async function waitFor(url: String, browser: Browser) { var ready = false; var context; do { try { context = await browser.newContext(); const page = await context.newPage(); await page.waitForTimeout(500); const result = await page.goto(url); ready = result.status() === 200; } catch(e) { if( !e.message.includes("CONNECTION_REFUSED") ){ throw e; } } finally { await context.close(); } } while(!ready); } export function startComposeService(serviceName: String){ console.log(`Starting ${serviceName}`); execSync(`docker compose --profile playwright --env-file test.env up -d ${serviceName}`); } export function stopComposeService(serviceName: String){ console.log(`Stopping ${serviceName}`); execSync(`docker compose --profile playwright --env-file test.env stop ${serviceName}`); } function wipeSqlite(){ console.log(`Delete Vaultwarden container to wipe sqlite`); execSync(`docker compose --env-file test.env stop Vaultwarden`); execSync(`docker compose --env-file test.env rm -f Vaultwarden`); } async function wipeMariaDB(){ var mysql = require('mysql2/promise'); var ready = false; var connection; do { try { connection = await mysql.createConnection({ user: process.env.MARIADB_USER, host: "127.0.0.1", database: process.env.MARIADB_DATABASE, password: process.env.MARIADB_PASSWORD, port: process.env.MARIADB_PORT, }); await connection.execute(`DROP DATABASE ${process.env.MARIADB_DATABASE}`); await connection.execute(`CREATE DATABASE ${process.env.MARIADB_DATABASE}`); console.log('Successfully wiped mariadb'); ready = true; } catch (err) { console.log(`Error when wiping mariadb: ${err}`); } finally { if( connection ){ connection.end(); } } await new Promise(r => setTimeout(r, 1000)); } while(!ready); } async function wipeMysqlDB(){ var mysql = require('mysql2/promise'); var ready = false; var connection; do{ try { connection = await mysql.createConnection({ user: process.env.MYSQL_USER, host: "127.0.0.1", database: process.env.MYSQL_DATABASE, password: process.env.MYSQL_PASSWORD, port: process.env.MYSQL_PORT, }); await connection.execute(`DROP DATABASE ${process.env.MYSQL_DATABASE}`); await connection.execute(`CREATE DATABASE ${process.env.MYSQL_DATABASE}`); console.log('Successfully wiped mysql'); ready = true; } catch (err) { console.log(`Error when wiping mysql: ${err}`); } finally { if( connection ){ connection.end(); } } await new Promise(r => setTimeout(r, 1000)); } while(!ready); } async function wipePostgres(){ const { Client } = require('pg'); const client = new Client({ user: process.env.POSTGRES_USER, host: "127.0.0.1", database: "postgres", password: process.env.POSTGRES_PASSWORD, port: process.env.POSTGRES_PORT, }); try { await client.connect(); await client.query(`DROP DATABASE ${process.env.POSTGRES_DB}`); await client.query(`CREATE DATABASE ${process.env.POSTGRES_DB}`); console.log('Successfully wiped postgres'); } catch (err) { console.log(`Error when wiping postgres: ${err}`); } finally { client.end(); } } function dbConfig(testInfo: TestInfo){ switch(testInfo.project.name) { case "postgres": case "sso-postgres": return { DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}` }; case "mariadb": case "sso-mariadb": return { DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PASSWORD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DATABASE}` }; case "mysql": case "sso-mysql": return { DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PASSWORD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DATABASE}`}; case "sqlite": case "sso-sqlite": return { I_REALLY_WANT_VOLATILE_STORAGE: true }; default: throw new Error(`Unknow database name: ${testInfo.project.name}`); } } /** * All parameters passed in `env` need to be added to the docker-compose.yml **/ export async function startVault(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) { if( resetDB ){ switch(testInfo.project.name) { case "postgres": case "sso-postgres": await wipePostgres(); break; case "mariadb": case "sso-mariadb": await wipeMariaDB(); break; case "mysql": case "sso-mysql": await wipeMysqlDB(); break; case "sqlite": case "sso-sqlite": wipeSqlite(); break; default: throw new Error(`Unknow database name: ${testInfo.project.name}`); } } console.log(`Starting Vaultwarden`); execSync(`docker compose --profile playwright --env-file test.env up -d Vaultwarden`, { env: { ...env, ...dbConfig(testInfo) }, }); await waitFor("/", browser); console.log(`Vaultwarden running on: ${process.env.DOMAIN}`); } export async function stopVault(force: boolean = false) { if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); } else { console.log(`Vaultwarden stopping`); execSync(`docker compose --profile playwright --env-file test.env stop Vaultwarden`); } } export async function restartVault(page: Page, testInfo: TestInfo, env, resetDB: Boolean = true) { stopVault(true); return startVault(page.context().browser(), testInfo, env, resetDB); } export async function checkNotification(page: Page, hasText: string) { await expect(page.locator('bit-toast', { hasText })).toBeVisible(); try { await page.locator('bit-toast', { hasText }).getByRole('button', { name: 'Close' }).click({force: true, timeout: 10_000}); } catch (error) { console.log(`Closing notification failed but it should now be invisible (${error})`); } await expect(page.locator('bit-toast', { hasText })).toHaveCount(0); } export async function cleanLanding(page: Page) { await page.goto('/', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('button').nth(0)).toBeVisible(); const logged = await page.getByRole('button', { name: 'Log out' }).count(); if( logged > 0 ){ await page.getByRole('button', { name: 'Log out' }).click(); await page.getByRole('button', { name: 'Log out' }).click(); } } export async function logout(test: Test, page: Page, user: { name: string }) { await test.step('logout', async () => { await page.getByRole('button', { name: user.name, exact: true }).click(); await page.getByRole('menuitem', { name: 'Log out' }).click(); await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); }); } export async function ignoreExtension(page: Page) { await page.waitForLoadState('domcontentloaded'); try { await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000}); await page.getByRole('link', { name: 'Skip to web app' }).click(); } catch (error) { console.log('Extension setup not visible. Continuing'); } } ================================================ FILE: playwright/package.json ================================================ { "name": "scenarios", "version": "1.0.0", "description": "", "main": "index.js", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@playwright/test": "1.56.1", "dotenv": "17.2.3", "dotenv-expand": "12.0.3", "maildev": "npm:@timshel_npm/maildev@3.2.5" }, "dependencies": { "mysql2": "3.15.3", "otpauth": "9.4.1", "pg": "8.16.3" } } ================================================ FILE: playwright/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; import { exec } from 'node:child_process'; const utils = require('./global-utils'); utils.loadEnv(); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './.', /* Run tests in files in parallel */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, retries: 0, workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Long global timeout for complex tests * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). */ timeout: 120 * 1000, actionTimeout: 20 * 1000, navigationTimeout: 20 * 1000, expect: { timeout: 20 * 1000 }, /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.DOMAIN, browserName: 'firefox', locale: 'en-GB', timezoneId: 'Europe/London', /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ trace: 'on', viewport: { width: 1080, height: 720, }, video: "on", }, /* Configure projects for major browsers */ projects: [ { name: 'mariadb-setup', testMatch: 'tests/setups/db-setup.ts', use: { serviceName: "Mariadb" }, teardown: 'mariadb-teardown', }, { name: 'mysql-setup', testMatch: 'tests/setups/db-setup.ts', use: { serviceName: "Mysql" }, teardown: 'mysql-teardown', }, { name: 'postgres-setup', testMatch: 'tests/setups/db-setup.ts', use: { serviceName: "Postgres" }, teardown: 'postgres-teardown', }, { name: 'sso-setup', testMatch: 'tests/setups/sso-setup.ts', teardown: 'sso-teardown', }, { name: 'mariadb', testMatch: 'tests/*.spec.ts', testIgnore: 'tests/sso_*.spec.ts', dependencies: ['mariadb-setup'], }, { name: 'mysql', testMatch: 'tests/*.spec.ts', testIgnore: 'tests/sso_*.spec.ts', dependencies: ['mysql-setup'], }, { name: 'postgres', testMatch: 'tests/*.spec.ts', testIgnore: 'tests/sso_*.spec.ts', dependencies: ['postgres-setup'], }, { name: 'sqlite', testMatch: 'tests/*.spec.ts', testIgnore: 'tests/sso_*.spec.ts', }, { name: 'sso-mariadb', testMatch: 'tests/sso_*.spec.ts', dependencies: ['sso-setup', 'mariadb-setup'], }, { name: 'sso-mysql', testMatch: 'tests/sso_*.spec.ts', dependencies: ['sso-setup', 'mysql-setup'], }, { name: 'sso-postgres', testMatch: 'tests/sso_*.spec.ts', dependencies: ['sso-setup', 'postgres-setup'], }, { name: 'sso-sqlite', testMatch: 'tests/sso_*.spec.ts', dependencies: ['sso-setup'], }, { name: 'mariadb-teardown', testMatch: 'tests/setups/db-teardown.ts', use: { serviceName: "Mariadb" }, }, { name: 'mysql-teardown', testMatch: 'tests/setups/db-teardown.ts', use: { serviceName: "Mysql" }, }, { name: 'postgres-teardown', testMatch: 'tests/setups/db-teardown.ts', use: { serviceName: "Postgres" }, }, { name: 'sso-teardown', testMatch: 'tests/setups/sso-teardown.ts', }, ], globalSetup: require.resolve('./global-setup'), }); ================================================ FILE: playwright/test.env ================================================ ################################################################## ### Shared Playwright conf test file Vaultwarden and Databases ### ################################################################## ENV=test DC_ENV_FILE=test.env COMPOSE_IGNORE_ORPHANS=True DOCKER_BUILDKIT=1 ##################### # Playwright Config # ##################### PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} PW_SMTP_FROM=vaultwarden@playwright.test ##################### # Maildev Config # ##################### MAILDEV_HTTP_PORT=1081 MAILDEV_SMTP_PORT=1026 MAILDEV_HOST=127.0.0.1 ################ # Users Config # ################ TEST_USER=test TEST_USER_PASSWORD=Master Password TEST_USER_MAIL=${TEST_USER}@example.com TEST_USER2=test2 TEST_USER2_PASSWORD=Master Password TEST_USER2_MAIL=${TEST_USER2}@example.com TEST_USER3=test3 TEST_USER3_PASSWORD=Master Password TEST_USER3_MAIL=${TEST_USER3}@example.com ################### # Keycloak Config # ################### KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_HTTP_HOST=127.0.0.1 KC_HTTP_PORT=8081 # Script parameters (use Keycloak and Vaultwarden config too) TEST_REALM=test DUMMY_REALM=dummy DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM} ###################### # Vaultwarden Config # ###################### ROCKET_PORT=8003 DOMAIN=http://localhost:${ROCKET_PORT} LOG_LEVEL=info,oidcwarden::sso=debug LOGIN_RATELIMIT_MAX_BURST=100 ADMIN_TOKEN=admin SMTP_SECURITY=off SMTP_PORT=${MAILDEV_SMTP_PORT} SMTP_FROM_NAME=Vaultwarden SMTP_TIMEOUT=5 SSO_CLIENT_ID=warden SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_DEBUG_TOKENS=true # Custom web-vault build # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 ########################### # Docker MariaDb container# ########################### MARIADB_PORT=3307 MARIADB_ROOT_PASSWORD=warden MARIADB_USER=warden MARIADB_PASSWORD=warden MARIADB_DATABASE=warden ########################### # Docker Mysql container# ########################### MYSQL_PORT=3309 MYSQL_ROOT_PASSWORD=warden MYSQL_USER=warden MYSQL_PASSWORD=warden MYSQL_DATABASE=warden ############################ # Docker Postgres container# ############################ POSTGRES_PORT=5433 POSTGRES_USER=warden POSTGRES_PASSWORD=warden POSTGRES_DB=warden ================================================ FILE: playwright/tests/collection.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import * as utils from "../global-utils"; import { createAccount } from './setups/user'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); }); test('Create', async ({ page }) => { await createAccount(test, page, users.user1); await test.step('Create Org', async () => { await page.getByRole('link', { name: 'New organisation' }).click(); await page.getByLabel('Organisation name (required)').fill('Test'); await page.getByRole('button', { name: 'Submit' }).click(); await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); await utils.checkNotification(page, 'Organisation created'); }); await test.step('Create Collection', async () => { await page.getByRole('link', { name: 'Collections' }).click(); await page.getByRole('button', { name: 'New' }).click(); await page.getByRole('menuitem', { name: 'Collection' }).click(); await page.getByLabel('Name (required)').fill('RandomCollec'); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'Created collection RandomCollec'); await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); }); }); ================================================ FILE: playwright/tests/login.smtp.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; const utils = require('../global-utils'); import { createAccount, logUser } from './setups/user'; import { activateEmail, retrieveEmailCode, disableEmail } from './setups/2fa'; let users = utils.loadEnv(); let mailserver; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { mailserver = new MailDev({ port: process.env.MAILDEV_SMTP_PORT, web: { port: process.env.MAILDEV_HTTP_PORT }, }) await mailserver.listen(); await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, }); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); if( mailserver ){ await mailserver.close(); } }); test('Account creation', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); await createAccount(test, page, users.user1, mailBuffer); mailBuffer.close(); }); test('Login', async ({ context, page }) => { const mailBuffer = mailserver.buffer(users.user1.email); await logUser(test, page, users.user1, mailBuffer); await test.step('verify email', async () => { await page.getByText('Verify your account\'s email').click(); await expect(page.getByText('Verify your account\'s email')).toBeVisible(); await page.getByRole('button', { name: 'Send email' }).click(); await utils.checkNotification(page, 'Check your email inbox for a verification link'); const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); expect(verify.from[0]?.address).toBe(process.env.PW_SMTP_FROM); const page2 = await context.newPage(); await page2.setContent(verify.html); const link = await page2.getByTestId("verify").getAttribute("href"); await page2.close(); await page.goto(link); await utils.checkNotification(page, 'Account email verified'); }); mailBuffer.close(); }); test('Activate 2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); await logUser(test, page, users.user1); await activateEmail(test, page, users.user1, emails); emails.close(); }); test('2fa', async ({ page }) => { const emails = mailserver.buffer(users.user1.email); await test.step('login', async () => { await page.goto('/'); await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); const code = await retrieveEmailCode(test, page, emails); await page.getByLabel(/Verification code/).fill(code); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Add it later' }).click(); await page.getByRole('link', { name: 'Skip to web app' }).click(); await expect(page).toHaveTitle(/Vaults/); }) await disableEmail(test, page, users.user1); emails.close(); }); ================================================ FILE: playwright/tests/login.spec.ts ================================================ import { test, expect, type Page, type TestInfo } from '@playwright/test'; import * as OTPAuth from "otpauth"; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; import { activateTOTP, disableTOTP } from './setups/2fa'; let users = utils.loadEnv(); let totp; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo, {}); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); }); test('Account creation', async ({ page }) => { await createAccount(test, page, users.user1); }); test('Master password login', async ({ page }) => { await logUser(test, page, users.user1); }); test('Authenticator 2fa', async ({ page }) => { await logUser(test, page, users.user1); let totp = await activateTOTP(test, page, users.user1); await utils.logout(test, page, users.user1); await test.step('login', async () => { let timestamp = Date.now(); // Needed to use the next token timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page).toHaveTitle(/Vaultwarden Web/); }); await disableTOTP(test, page, users.user1); }); ================================================ FILE: playwright/tests/organization.smtp.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from '../global-utils'; import * as orgs from './setups/orgs'; import { createAccount, logUser } from './setups/user'; let users = utils.loadEnv(); let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { mailServer = new MailDev({ port: process.env.MAILDEV_SMTP_PORT, web: { port: process.env.MAILDEV_HTTP_PORT }, }) await mailServer.listen(); await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, }); mail1Buffer = mailServer.buffer(users.user1.email); mail2Buffer = mailServer.buffer(users.user2.email); mail3Buffer = mailServer.buffer(users.user3.email); }); test.afterAll('Teardown', async ({}, testInfo: TestInfo) => { utils.stopVault(testInfo); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); }); test('Create user3', async ({ page }) => { await createAccount(test, page, users.user3, mail3Buffer); }); test('Invite users', async ({ page }) => { await createAccount(test, page, users.user1, mail1Buffer); await orgs.create(test, page, 'Test'); await orgs.members(test, page, 'Test'); await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user3.email, { navigate: false, }); }); test('invited with new account', async ({ page }) => { const invited = await mail2Buffer.expect((mail) => mail.subject === 'Join Test'); await test.step('Create account', async () => { await page.setContent(invited.html); const link = await page.getByTestId('invite').getAttribute('href'); await page.goto(link); await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); //await page.getByLabel('Name').fill(users.user2.name); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created'); await utils.checkNotification(page, 'Invitation accepted'); await utils.ignoreExtension(page); // Redirected to the vault await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); // await utils.checkNotification(page, 'You have been logged in!'); }); await test.step('Check mails', async () => { await mail2Buffer.expect((m) => m.subject === 'Welcome'); await mail2Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); }); }); test('invited with existing account', async ({ page }) => { const invited = await mail3Buffer.expect((mail) => mail.subject === 'Join Test'); await page.setContent(invited.html); const link = await page.getByTestId('invite').getAttribute('href'); await page.goto(link); // We should be on login page with email prefilled await expect(page).toHaveTitle(/Vaultwarden Web/); await page.getByRole('button', { name: 'Continue' }).click(); // Unlock page await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); await utils.checkNotification(page, 'Invitation accepted'); await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); }); test('Confirm invited user', async ({ page }) => { await logUser(test, page, users.user1, mail1Buffer); await orgs.members(test, page, 'Test'); await orgs.confirm(test, page, 'Test', users.user2.email); await mail2Buffer.expect((m) => m.subject.includes('Invitation to Test confirmed')); }); test('Organization is visible', async ({ page }) => { await logUser(test, page, users.user2, mail2Buffer); await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); ================================================ FILE: playwright/tests/organization.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from "../global-utils"; import * as orgs from './setups/orgs'; import { createAccount, logUser } from './setups/user'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); }); test('Invite', async ({ page }) => { await createAccount(test, page, users.user3); await createAccount(test, page, users.user1); await orgs.create(test, page, 'New organisation'); await orgs.members(test, page, 'New organisation'); await test.step('missing user2', async () => { await orgs.invite(test, page, 'New organisation', users.user2.email); await expect(page.getByRole('row', { name: users.user2.email })).toHaveText(/Invited/); }); await test.step('existing user3', async () => { await orgs.invite(test, page, 'New organisation', users.user3.email); await expect(page.getByRole('row', { name: users.user3.email })).toHaveText(/Needs confirmation/); await orgs.confirm(test, page, 'New organisation', users.user3.email); }); await test.step('confirm user2', async () => { await createAccount(test, page, users.user2); await logUser(test, page, users.user1); await orgs.members(test, page, 'New organisation'); await orgs.confirm(test, page, 'New organisation', users.user2.email); }); await test.step('Org visible user2 ', async () => { await logUser(test, page, users.user2); await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); await test.step('Org visible user3 ', async () => { await logUser(test, page, users.user3); await page.getByRole('button', { name: 'vault: New organisation', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); }); ================================================ FILE: playwright/tests/setups/2fa.ts ================================================ import { expect, type Page, Test } from '@playwright/test'; import { type MailBuffer } from 'maildev'; import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { return await test.step('Activate TOTP 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); await page.getByRole('menuitem', { name: 'Account settings' }).click(); await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); const secret = await page.getByLabel('Key').innerText(); let totp = new OTPAuth.TOTP({ secret, period: 30 }); await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); await page.getByLabel('Close').click(); return totp; }) } export async function disableTOTP(test: Test, page: Page, user: { password: string }) { await test.step('Disable TOTP 2FA', async () => { await page.getByRole('button', { name: 'Test' }).click(); await page.getByRole('menuitem', { name: 'Account settings' }).click(); await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); await page.getByLabel('Master password (required)').click(); await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); await utils.checkNotification(page, 'Two-step login provider turned off'); }); } export async function activateEmail(test: Test, page: Page, user: { name: string, password: string }, mailBuffer: MailBuffer) { await test.step('Activate Email 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); await page.getByRole('menuitem', { name: 'Account settings' }).click(); await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Send email' }).click(); }); let code = await retrieveEmailCode(test, page, mailBuffer); await test.step('input code', async () => { await page.getByLabel('2. Enter the resulting 6').fill(code); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); }); } export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { return await test.step('retrieve code', async () => { const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); const page2 = await page.context().newPage(); await page2.setContent(codeMail.html); const code = await page2.getByTestId("2fa").innerText(); await page2.close(); return code; }); } export async function disableEmail(test: Test, page: Page, user: { password: string }) { await test.step('Disable Email 2FA', async () => { await page.getByRole('button', { name: 'Test' }).click(); await page.getByRole('menuitem', { name: 'Account settings' }).click(); await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click(); await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); await page.getByLabel('Master password (required)').click(); await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Yes' }).click(); await utils.checkNotification(page, 'Two-step login provider turned off'); }); } ================================================ FILE: playwright/tests/setups/db-setup.ts ================================================ import { test } from './db-test'; const utils = require('../../global-utils'); test('DB start', async ({ serviceName }) => { utils.startComposeService(serviceName); }); ================================================ FILE: playwright/tests/setups/db-teardown.ts ================================================ import { test } from './db-test'; const utils = require('../../global-utils'); utils.loadEnv(); test('DB teardown ?', async ({ serviceName }) => { if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { utils.stopComposeService(serviceName); } }); ================================================ FILE: playwright/tests/setups/db-test.ts ================================================ import { test as base } from '@playwright/test'; export type TestOptions = { serviceName: string; }; export const test = base.extend({ serviceName: ['', { option: true }], }); ================================================ FILE: playwright/tests/setups/orgs.ts ================================================ import { expect, type Browser,Page } from '@playwright/test'; import * as utils from '../../global-utils'; export async function create(test, page: Page, name: string) { await test.step('Create Org', async () => { await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await page.getByRole('link', { name: 'New organisation' }).click(); await page.getByLabel('Organisation name (required)').fill(name); await page.getByRole('button', { name: 'Submit' }).click(); await utils.checkNotification(page, 'Organisation created'); }); } export async function policies(test, page: Page, name: string) { await test.step(`Navigate to ${name} policies`, async () => { await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); await page.getByRole('link', { name: 'Policies' }).click(); await expect(page.getByRole('heading', { name: 'Policies' })).toBeVisible(); }); } export async function members(test, page: Page, name: string) { await test.step(`Navigate to ${name} members`, async () => { await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); }); } export async function invite(test, page: Page, name: string, email: string) { await test.step(`Invite ${email}`, async () => { await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await page.getByRole('button', { name: 'Invite member' }).click(); await page.getByLabel('Email (required)').fill(email); await page.getByRole('tab', { name: 'Collections' }).click(); await page.getByRole('combobox', { name: 'Permission' }).click(); await page.getByText('Edit items', { exact: true }).click(); await page.getByLabel('Select collections').click(); await page.getByText('Default collection').click(); await page.getByRole('cell', { name: 'Collection', exact: true }).click(); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'User(s) invited'); }); } export async function confirm(test, page: Page, name: string, user_email: string) { await test.step(`Confirm ${user_email}`, async () => { await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); await page.getByRole('menuitem', { name: 'Confirm' }).click(); await expect(page.getByRole('heading', { name: 'Confirm user' })).toBeVisible(); await page.getByRole('button', { name: 'Confirm' }).click(); await utils.checkNotification(page, 'confirmed'); }); } export async function revoke(test, page: Page, name: string, user_email: string) { await test.step(`Revoke ${user_email}`, async () => { await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await page.getByRole('row').filter({hasText: user_email}).getByLabel('Options').click(); await page.getByRole('menuitem', { name: 'Revoke access' }).click(); await expect(page.getByRole('heading', { name: 'Revoke access' })).toBeVisible(); await page.getByRole('button', { name: 'Revoke access' }).click(); await utils.checkNotification(page, 'Revoked organisation access'); }); } ================================================ FILE: playwright/tests/setups/sso-setup.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; const { exec } = require('node:child_process'); const utils = require('../../global-utils'); utils.loadEnv(); test.beforeAll('Setup', async () => { console.log("Starting Keycloak"); exec(`docker compose --profile keycloak --env-file test.env up`); }); test('Keycloak is up', async ({ page }) => { await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser()); // Dummy authority is created at the end of the setup await utils.waitFor(process.env.DUMMY_AUTHORITY, page.context().browser()); console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`); }); ================================================ FILE: playwright/tests/setups/sso-teardown.ts ================================================ import { test, type FullConfig } from '@playwright/test'; const { execSync } = require('node:child_process'); const utils = require('../../global-utils'); utils.loadEnv(); test('Keycloak teardown', async () => { if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { console.log("Keep Keycloak running"); } else { console.log("Keycloak stopping"); execSync(`docker compose --profile keycloak --env-file test.env stop Keycloak`); } }); ================================================ FILE: playwright/tests/setups/sso.ts ================================================ import { expect, type Page, Test } from '@playwright/test'; import { type MailBuffer, MailServer } from 'maildev'; import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; /** * If a MailBuffer is passed it will be used and consume the expected emails */ export async function logNewUser( test: Test, page: Page, user: { email: string, name: string, password: string }, options: { mailBuffer?: MailBuffer } = {} ) { await test.step(`Create user ${user.name}`, async () => { await page.context().clearCookies(); await test.step('Landing page', async () => { await utils.cleanLanding(page); await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.getByRole('button', { name: /Use single sign-on/ }).click(); }); await test.step('Keycloak login', async () => { await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); await page.getByLabel(/Username/).fill(user.name); await page.getByLabel('Password', { exact: true }).fill(user.password); await page.getByRole('button', { name: 'Sign In' }).click(); }); await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('button', { name: 'Create account' }).click(); }); await utils.checkNotification(page, 'Account successfully created!'); await utils.checkNotification(page, 'Invitation accepted'); await utils.ignoreExtension(page); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); }); if( options.mailBuffer ){ let mailBuffer = options.mailBuffer; await test.step('Check emails', async () => { await mailBuffer.expect((m) => m.subject === "Welcome"); await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); }); } }); } /** * If a MailBuffer is passed it will be used and consume the expected emails */ export async function logUser( test: Test, page: Page, user: { email: string, password: string }, options: { mailBuffer ?: MailBuffer, totp?: OTPAuth.TOTP, mail2fa?: boolean, } = {} ) { let mailBuffer = options.mailBuffer; await test.step(`Log user ${user.email}`, async () => { await page.context().clearCookies(); await test.step('Landing page', async () => { await utils.cleanLanding(page); await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.getByRole('button', { name: /Use single sign-on/ }).click(); }); await test.step('Keycloak login', async () => { await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); await page.getByLabel(/Username/).fill(user.name); await page.getByLabel('Password', { exact: true }).fill(user.password); await page.getByRole('button', { name: 'Sign In' }).click(); }); if( options.totp || options.mail2fa ){ let code; await test.step('2FA check', async () => { await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); if( options.totp ) { const totp = options.totp; let timestamp = Date.now(); // Needed to use the next token timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; code = totp.generate({timestamp}); } else if( options.mail2fa ){ code = await retrieveEmailCode(test, page, mailBuffer); } await page.getByLabel(/Verification code/).fill(code); await page.getByRole('button', { name: 'Continue' }).click(); }); } await test.step('Unlock vault', async () => { await expect(page).toHaveTitle('Vaultwarden Web'); await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Unlock' }).click(); }); await utils.ignoreExtension(page); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); }); if( mailBuffer ){ await test.step('Check email', async () => { await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); }); } }); } ================================================ FILE: playwright/tests/setups/user.ts ================================================ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; import * as utils from '../../global-utils'; export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { await test.step(`Create user ${user.name}`, async () => { await utils.cleanLanding(page); await page.getByRole('link', { name: 'Create account' }).click(); // Back to Vault create account await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); await page.getByLabel(/Email address/).fill(user.email); await page.getByLabel('Name').fill(user.name); await page.getByRole('button', { name: 'Continue' }).click(); // Vault finish Creation await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Your new account has been created') await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); // await utils.checkNotification(page, 'You have been logged in!'); if( mailBuffer ){ await mailBuffer.expect((m) => m.subject === "Welcome"); await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); } }); } export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { await test.step(`Log user ${user.email}`, async () => { await utils.cleanLanding(page); await page.getByLabel(/Email address/).fill(user.email); await page.getByRole('button', { name: 'Continue' }).click(); // Unlock page await page.getByLabel('Master password').fill(user.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); await utils.ignoreExtension(page); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); if( mailBuffer ){ await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); } }); } ================================================ FILE: playwright/tests/sso_login.smtp.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import { logNewUser, logUser } from './setups/sso'; import { activateEmail, disableEmail } from './setups/2fa'; import * as utils from "../global-utils"; let users = utils.loadEnv(); let mailserver; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { mailserver = new MailDev({ port: process.env.MAILDEV_SMTP_PORT, web: { port: process.env.MAILDEV_HTTP_PORT }, }) await mailserver.listen(); await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: false, SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, }); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); if( mailserver ){ await mailserver.close(); } }); test('Create and activate 2FA', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); await activateEmail(test, page, users.user1, mailBuffer); mailBuffer.close(); }); test('Log and disable', async ({ page }) => { const mailBuffer = mailserver.buffer(users.user1.email); await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); await disableEmail(test, page, users.user1); mailBuffer.close(); }); ================================================ FILE: playwright/tests/sso_login.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { logNewUser, logUser } from './setups/sso'; import { activateTOTP, disableTOTP } from './setups/2fa'; import * as utils from "../global-utils"; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: false }); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); }); test('Account creation using SSO', async ({ page }) => { // Landing page await logNewUser(test, page, users.user1); }); test('SSO login', async ({ page }) => { await logUser(test, page, users.user1); }); test('Non SSO login', async ({ page }) => { // Landing page await page.goto('/'); await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); await page.getByRole('button', { name: 'Other' }).click(); // Unlock page await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('button', { name: 'Log in with master password' }).click(); // We are now in the default vault page await expect(page).toHaveTitle(/Vaultwarden Web/); }); test('SSO login with TOTP 2fa', async ({ page }) => { await logUser(test, page, users.user1); let totp = await activateTOTP(test, page, users.user1); await logUser(test, page, users.user1, { totp }); await disableTOTP(test, page, users.user1); }); test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) => { await utils.restartVault(page, testInfo, { SSO_ENABLED: true, SSO_ONLY: true }, false); // Landing page await page.goto('/'); // Check that SSO login is available await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); // No Continue/Other await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0); }); test('No SSO login', async ({ page }, testInfo: TestInfo) => { await utils.restartVault(page, testInfo, { SSO_ENABLED: false }, false); // Landing page await page.goto('/'); // No SSO button (rely on a correct selector checked in previous test) await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); // Can continue to Master password await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1); }); ================================================ FILE: playwright/tests/sso_organization.smtp.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from "../global-utils"; import * as orgs from './setups/orgs'; import { logNewUser, logUser } from './setups/sso'; let users = utils.loadEnv(); let mailServer, mail1Buffer, mail2Buffer, mail3Buffer; test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { mailServer = new MailDev({ port: process.env.MAILDEV_SMTP_PORT, web: { port: process.env.MAILDEV_HTTP_PORT }, }) await mailServer.listen(); await utils.startVault(browser, testInfo, { SMTP_HOST: process.env.MAILDEV_HOST, SMTP_FROM: process.env.PW_SMTP_FROM, SSO_ENABLED: true, SSO_ONLY: true, }); mail1Buffer = mailServer.buffer(users.user1.email); mail2Buffer = mailServer.buffer(users.user2.email); mail3Buffer = mailServer.buffer(users.user3.email); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); [mail1Buffer, mail2Buffer, mail3Buffer, mailServer].map((m) => m?.close()); }); test('Create user3', async ({ page }) => { await logNewUser(test, page, users.user3, { mailBuffer: mail3Buffer }); }); test('Invite users', async ({ page }) => { await logNewUser(test, page, users.user1, { mailBuffer: mail1Buffer }); await orgs.create(test, page, '/Test'); await orgs.members(test, page, '/Test'); await orgs.invite(test, page, '/Test', users.user2.email); await orgs.invite(test, page, '/Test', users.user3.email); }); test('invited with new account', async ({ page }) => { const link = await test.step('Extract email link', async () => { const invited = await mail2Buffer.expect((m) => m.subject === "Join /Test"); await page.setContent(invited.html); return await page.getByTestId("invite").getAttribute("href"); }); await test.step('Redirect to Keycloak', async () => { await page.goto(link); }); await test.step('Keycloak login', async () => { await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); await page.getByLabel(/Username/).fill(users.user2.name); await page.getByLabel('Password', { exact: true }).fill(users.user2.password); await page.getByRole('button', { name: 'Sign In' }).click(); }); await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('button', { name: 'Create account' }).click(); await utils.checkNotification(page, 'Account successfully created!'); await utils.checkNotification(page, 'Invitation accepted'); await utils.ignoreExtension(page); }); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); }); await test.step('Check mails', async () => { await mail2Buffer.expect((m) => m.subject.includes("New Device Logged")); await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); }); }); test('invited with existing account', async ({ page }) => { const link = await test.step('Extract email link', async () => { const invited = await mail3Buffer.expect((m) => m.subject === "Join /Test"); await page.setContent(invited.html); return await page.getByTestId("invite").getAttribute("href"); }); await test.step('Redirect to Keycloak', async () => { await page.goto(link); }); await test.step('Keycloak login', async () => { await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); await page.getByLabel(/Username/).fill(users.user3.name); await page.getByLabel('Password', { exact: true }).fill(users.user3.password); await page.getByRole('button', { name: 'Sign In' }).click(); }); await test.step('Unlock vault', async () => { await expect(page).toHaveTitle('Vaultwarden Web'); await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('button', { name: 'Unlock' }).click(); await utils.checkNotification(page, 'Invitation accepted'); await utils.ignoreExtension(page); }); await test.step('Default vault page', async () => { await expect(page).toHaveTitle(/Vaultwarden Web/); }); await test.step('Check mails', async () => { await mail3Buffer.expect((m) => m.subject.includes("New Device Logged")); await mail1Buffer.expect((m) => m.subject === "Invitation to /Test accepted"); }); }); ================================================ FILE: playwright/tests/sso_organization.spec.ts ================================================ import { test, expect, type TestInfo } from '@playwright/test'; import { MailDev } from 'maildev'; import * as utils from "../global-utils"; import * as orgs from './setups/orgs'; import { logNewUser, logUser } from './setups/sso'; let users = utils.loadEnv(); test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { await utils.startVault(browser, testInfo, { SSO_ENABLED: true, SSO_ONLY: true, }); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); }); test('Create user3', async ({ page }) => { await logNewUser(test, page, users.user3); }); test('Invite users', async ({ page }) => { await logNewUser(test, page, users.user1); await orgs.create(test, page, '/Test'); await orgs.members(test, page, '/Test'); await orgs.invite(test, page, '/Test', users.user2.email); await orgs.invite(test, page, '/Test', users.user3.email); await orgs.confirm(test, page, '/Test', users.user3.email); }); test('Create invited account', async ({ page }) => { await logNewUser(test, page, users.user2); }); test('Confirm invited user', async ({ page }) => { await logUser(test, page, users.user1); await orgs.members(test, page, '/Test'); await expect(page.getByRole('row', { name: users.user2.name })).toHaveText(/Needs confirmation/); await orgs.confirm(test, page, '/Test', users.user2.email); }); test('Organization is visible', async ({ page }) => { await logUser(test, page, users.user2); await page.getByLabel('vault: /Test').click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); test('Enforce password policy', async ({ page }) => { await logUser(test, page, users.user1); await orgs.policies(test, page, '/Test'); await test.step(`Set master password policy`, async () => { await page.getByRole('button', { name: 'Master password requirements' }).click(); await page.getByRole('checkbox', { name: 'Turn on' }).check(); await page.getByRole('checkbox', { name: 'Require existing members to' }).check(); await page.getByRole('spinbutton', { name: 'Minimum length' }).fill('42'); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'Edited policy Master password requirements.'); }); await utils.logout(test, page, users.user1); await test.step(`Unlock trigger policy`, async () => { await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); await page.getByRole('button', { name: 'Use single sign-on' }).click(); await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); await page.getByRole('button', { name: 'Unlock' }).click(); await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); }); }); ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "1.94.0" components = [ "rustfmt", "clippy" ] profile = "minimal" ================================================ FILE: rustfmt.toml ================================================ edition = "2021" max_width = 120 newline_style = "Unix" use_small_heuristics = "Off" ================================================ FILE: src/api/admin.rs ================================================ use std::{env, sync::LazyLock}; use reqwest::Method; use rocket::{ form::Form, http::{Cookie, CookieJar, MediaType, SameSite, Status}, request::{FromRequest, Outcome, Request}, response::{content::RawHtml as Html, Redirect}, serde::json::Json, Catcher, Route, }; use serde::de::DeserializeOwned; use serde_json::Value; use crate::{ api::{ core::{log_event, two_factor}, unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, }, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure}, config::ConfigBuilder, db::{ backup_sqlite, get_sql_server_version, models::{ Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, }, DbConn, DbConnType, ACTIVE_DB_TYPE, }, error::{Error, MapResult}, http_client::make_http_request, mail, util::{ container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size, is_running_in_container, NumberOrString, }, CONFIG, VERSION, }; pub fn routes() -> Vec { if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() { return routes![admin_disabled]; } routes![ get_users_json, get_user_json, get_user_by_mail_json, post_admin_login, admin_page, admin_page_login, invite_user, logout, delete_user, delete_sso_user, deauth_user, disable_user, enable_user, remove_2fa, update_membership_type, update_revision_users, post_config, delete_config, backup_db, test_smtp, users_overview, organizations_overview, delete_organization, diagnostics, get_diagnostics_config, resend_user_invite, get_diagnostics_http, ] } pub fn catchers() -> Vec { if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() { catchers![] } else { catchers![admin_login] } } static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() { #[cfg(mysql)] Some(DbConnType::Mysql) => "MySQL", #[cfg(postgresql)] Some(DbConnType::Postgresql) => "PostgreSQL", #[cfg(sqlite)] Some(DbConnType::Sqlite) => "SQLite", _ => "Unknown", }); #[cfg(sqlite)] static CAN_BACKUP: LazyLock = LazyLock::new(|| ACTIVE_DB_TYPE.get().map(|t| *t == DbConnType::Sqlite).unwrap_or(false)); #[cfg(not(sqlite))] static CAN_BACKUP: LazyLock = LazyLock::new(|| false); #[get("/")] fn admin_disabled() -> &'static str { "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it" } const COOKIE_NAME: &str = "VW_ADMIN"; const ADMIN_PATH: &str = "/admin"; const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z"; const BASE_TEMPLATE: &str = "admin/base"; const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000"; pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000"; fn admin_path() -> String { format!("{}{ADMIN_PATH}", CONFIG.domain_path()) } #[derive(Debug)] struct IpHeader(Option); #[rocket::async_trait] impl<'r> FromRequest<'r> for IpHeader { type Error = (); async fn from_request(req: &'r Request<'_>) -> Outcome { if req.headers().get_one(&CONFIG.ip_header()).is_some() { Outcome::Success(IpHeader(Some(CONFIG.ip_header()))) } else if req.headers().get_one("X-Client-IP").is_some() { Outcome::Success(IpHeader(Some(String::from("X-Client-IP")))) } else if req.headers().get_one("X-Real-IP").is_some() { Outcome::Success(IpHeader(Some(String::from("X-Real-IP")))) } else if req.headers().get_one("X-Forwarded-For").is_some() { Outcome::Success(IpHeader(Some(String::from("X-Forwarded-For")))) } else { Outcome::Success(IpHeader(None)) } } } fn admin_url() -> String { format!("{}{}", CONFIG.domain_origin(), admin_path()) } #[derive(Responder)] enum AdminResponse { #[response(status = 200)] Ok(ApiResult>), #[response(status = 401)] Unauthorized(ApiResult>), #[response(status = 429)] TooManyRequests(ApiResult>), } #[catch(401)] fn admin_login(request: &Request<'_>) -> ApiResult> { if request.format() == Some(&MediaType::JSON) { err_code!("Authorization failed.", Status::Unauthorized.code); } let redirect = request.segments::(0..).unwrap_or_default().display().to_string(); render_admin_login(None, Some(&redirect)) } fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult> { // If there is an error, show it let msg = msg.map(|msg| format!("Error: {msg}")); let json = json!({ "page_content": "admin/login", "error": msg, "redirect": redirect, "urlpath": CONFIG.domain_path() }); // Return the page let text = CONFIG.render_template(BASE_TEMPLATE, &json)?; Ok(Html(text)) } #[derive(FromForm)] struct LoginForm { token: String, redirect: Option, } #[post("/", format = "application/x-www-form-urlencoded", data = "")] fn post_admin_login( data: Form, cookies: &CookieJar<'_>, ip: ClientIp, secure: Secure, ) -> Result { let data = data.into_inner(); let redirect = data.redirect; if crate::ratelimit::check_limit_admin(&ip.ip).is_err() { return Err(AdminResponse::TooManyRequests(render_admin_login( Some("Too many requests, try again later."), redirect.as_deref(), ))); } // If the token is invalid, redirect to login page if !_validate_token(&data.token) { error!("Invalid admin token. IP: {}", ip.ip); Err(AdminResponse::Unauthorized(render_admin_login( Some("Invalid admin token, please try again."), redirect.as_deref(), ))) } else { // If the token received is valid, generate JWT and save it as a cookie let claims = generate_admin_claims(); let jwt = encode_jwt(&claims); let cookie = Cookie::build((COOKIE_NAME, jwt)) .path(admin_path()) .max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) .same_site(SameSite::Strict) .http_only(true) .secure(secure.https); cookies.add(cookie); if let Some(redirect) = redirect { Ok(Redirect::to(format!("{}{redirect}", admin_path()))) } else { Err(AdminResponse::Ok(render_admin_page())) } } } fn _validate_token(token: &str) -> bool { match CONFIG.admin_token().as_ref() { None => false, Some(t) if t.starts_with("$argon2") => { use argon2::password_hash::PasswordVerifier; match argon2::password_hash::PasswordHash::new(t) { Ok(h) => { // NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance. argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok() } Err(e) => { error!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: {e}"); false } } } Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()), } } #[derive(Serialize)] struct AdminTemplateData { page_content: String, page_data: Option, logged_in: bool, urlpath: String, sso_enabled: bool, } impl AdminTemplateData { fn new(page_content: &str, page_data: Value) -> Self { Self { page_content: String::from(page_content), page_data: Some(page_data), logged_in: true, urlpath: CONFIG.domain_path(), sso_enabled: CONFIG.sso_enabled(), } } fn render(self) -> Result { CONFIG.render_template(BASE_TEMPLATE, &self) } } fn render_admin_page() -> ApiResult> { let settings_json = json!({ "config": CONFIG.prepare_json(), "can_backup": *CAN_BACKUP, }); let text = AdminTemplateData::new("admin/settings", settings_json).render()?; Ok(Html(text)) } #[get("/")] fn admin_page(_token: AdminToken) -> ApiResult> { render_admin_page() } #[get("/", rank = 2)] fn admin_page_login() -> ApiResult> { render_admin_login(None, None) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { email: String, } async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult { if let Some(user) = User::find_by_uuid(user_id, conn).await { Ok(user) } else { err_code!("User doesn't exist", Status::NotFound.code); } } #[post("/invite", format = "application/json", data = "")] async fn invite_user(data: Json, _token: AdminToken, conn: DbConn) -> JsonResult { let data: InviteData = data.into_inner(); if User::find_by_mail(&data.email, &conn).await.is_some() { err_code!("User already exists", Status::Conflict.code) } let mut user = User::new(&data.email, None); async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult { if CONFIG.mail_enabled() { let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await } else { let invitation = Invitation::new(&user.email); invitation.save(conn).await } } _generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?; Ok(Json(user.to_json(&conn).await)) } #[post("/test/smtp", format = "application/json", data = "")] async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { let data: InviteData = data.into_inner(); if CONFIG.mail_enabled() { mail::send_test(&data.email).await } else { err!("Mail is not enabled") } } #[get("/logout")] fn logout(cookies: &CookieJar<'_>) -> Redirect { cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); Redirect::to(admin_path()) } #[get("/users")] async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json { let users = User::get_all(&conn).await; let mut users_json = Vec::with_capacity(users.len()); for (u, _) in users { let mut usr = u.to_json(&conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); usr["lastActive"] = match u.last_active(&conn).await { Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)), None => json!(None::), }; users_json.push(usr); } Json(Value::Array(users_json)) } #[get("/users/overview")] async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { let users = User::get_all(&conn).await; let mut users_json = Vec::with_capacity(users.len()); for (u, sso_u) in users { let mut usr = u.to_json(&conn).await; usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await); usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await); usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn).await)); usr["user_enabled"] = json!(u.enabled); usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); usr["last_active"] = match u.last_active(&conn).await { Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)), None => json!("Never"), }; usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new())); users_json.push(usr); } let text = AdminTemplateData::new("admin/users", json!(users_json)).render()?; Ok(Html(text)) } #[get("/users/by-mail/")] async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult { if let Some(u) = User::find_by_mail(mail, &conn).await { let mut usr = u.to_json(&conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); Ok(Json(usr)) } else { err_code!("User doesn't exist", Status::NotFound.code); } } #[get("/users/")] async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> JsonResult { let u = get_user_or_404(&user_id, &conn).await?; let mut usr = u.to_json(&conn).await; usr["userEnabled"] = json!(u.enabled); usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT)); Ok(Json(usr)) } #[post("/users//delete", format = "application/json")] async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let user = get_user_or_404(&user_id, &conn).await?; // Get the membership records before deleting the actual user let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; let res = user.delete(&conn).await; for membership in memberships { log_event( EventType::OrganizationUserDeleted as i32, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), 14, // Use UnknownBrowser type &token.ip.ip, &conn, ) .await; } res } #[delete("/users//sso", format = "application/json")] async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; let res = SsoUser::delete(&user_id, &conn).await; for membership in memberships { log_event( EventType::OrganizationUserUnlinkedSso as i32, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), 14, // Use UnknownBrowser type &token.ip.ip, &conn, ) .await; } res } #[post("/users//deauth", format = "application/json")] async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; nt.send_logout(&user, None, &conn).await; if CONFIG.push_enabled() { for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { match unregister_push_device(&device.push_uuid).await { Ok(r) => r, Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"), }; } } Device::delete_all_by_user(&user.uuid, &conn).await?; user.reset_security_stamp(); user.save(&conn).await } #[post("/users//disable", format = "application/json")] async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; Device::delete_all_by_user(&user.uuid, &conn).await?; user.reset_security_stamp(); user.enabled = false; let save_result = user.save(&conn).await; nt.send_logout(&user, None, &conn).await; save_result } #[post("/users//enable", format = "application/json")] async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; user.enabled = true; user.save(&conn).await } #[post("/users//remove-2fa", format = "application/json")] async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { let mut user = get_user_or_404(&user_id, &conn).await?; TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?; user.totp_recover = None; user.save(&conn).await } #[post("/users//invite/resend", format = "application/json")] async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult { if let Some(user) = User::find_by_uuid(&user_id, &conn).await { //TODO: replace this with user.status check when it will be available (PR#3397) if !user.password_hash.is_empty() { err_code!("User already accepted invitation", Status::BadRequest.code); } if CONFIG.mail_enabled() { let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await } else { Ok(()) } } else { err_code!("User doesn't exist", Status::NotFound.code); } } #[derive(Debug, Deserialize)] struct MembershipTypeData { user_type: NumberOrString, user_uuid: UserId, org_uuid: OrganizationId, } #[post("/users/org_type", format = "application/json", data = "")] async fn update_membership_type(data: Json, token: AdminToken, conn: DbConn) -> EmptyResult { let data: MembershipTypeData = data.into_inner(); let Some(mut member_to_edit) = Membership::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn).await else { err!("The specified user isn't member of the organization") }; let new_type = match MembershipType::from_str(&data.user_type.into_string()) { Some(new_type) => new_type as i32, None => err!("Invalid type"), }; if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { // Removing owner permission, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &conn).await <= 1 { err!("Can't change the type of the last owner") } } member_to_edit.atype = new_type; // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; log_event( EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, &data.org_uuid, &ACTING_ADMIN_USER.into(), 14, // Use UnknownBrowser type &token.ip.ip, &conn, ) .await; member_to_edit.save(&conn).await } #[post("/users/update_revision", format = "application/json")] async fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult { User::update_all_revisions(&conn).await } #[get("/organizations/overview")] async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult> { let organizations = Organization::get_all(&conn).await; let mut organizations_json = Vec::with_capacity(organizations.len()); for o in organizations { let mut org = o.to_json(); org["user_count"] = json!(Membership::count_by_org(&o.uuid, &conn).await); org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn).await); org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &conn).await); org["group_count"] = json!(Group::count_by_org(&o.uuid, &conn).await); org["event_count"] = json!(Event::count_by_org(&o.uuid, &conn).await); org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn).await); org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn).await)); organizations_json.push(org); } let text = AdminTemplateData::new("admin/organizations", json!(organizations_json)).render()?; Ok(Html(text)) } #[post("/organizations//delete", format = "application/json")] async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: DbConn) -> EmptyResult { let org = Organization::find_by_uuid(&org_id, &conn).await.map_res("Organization doesn't exist")?; org.delete(&conn).await } #[derive(Deserialize)] struct GitRelease { tag_name: String, } #[derive(Deserialize)] struct GitCommit { sha: String, } async fn get_json_api(url: &str) -> Result { Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::().await?) } async fn get_text_api(url: &str) -> Result { Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.text().await?) } async fn has_http_access() -> bool { let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else { return false; }; match req.send().await { Ok(r) => r.status().is_success(), _ => false, } } use cached::proc_macro::cached; /// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already /// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit /// Any cache will be lost if Vaultwarden is restarted use std::time::Duration; // Needed for cached #[cached(time = 600, sync_writes = "default")] async fn get_release_info(has_http_access: bool) -> (String, String, String) { // If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway. if has_http_access { ( match get_json_api::("https://api.github.com/repos/dani-garcia/vaultwarden/releases/latest") .await { Ok(r) => r.tag_name, _ => "-".to_string(), }, match get_json_api::("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await { Ok(mut c) => { c.sha.truncate(8); c.sha } _ => "-".to_string(), }, // Do not fetch the web-vault version when running within a container // The web-vault version is embedded within the container it self, and should not be updated manually match get_json_api::("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest") .await { Ok(r) => r.tag_name.trim_start_matches('v').to_string(), _ => "-".to_string(), }, ) } else { ("-".to_string(), "-".to_string(), "-".to_string()) } } async fn get_ntp_time(has_http_access: bool) -> String { if has_http_access { if let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await { for line in cf_trace.lines() { if let Some((key, value)) = line.split_once('=') { if key == "ts" { let ts = value.split_once('.').map_or(value, |(s, _)| s); if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") { return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(); } break; } } } } } String::from("Unable to fetch NTP time.") } fn web_vault_compare(active: &str, latest: &str) -> i8 { use semver::Version; use std::cmp::Ordering; let active_semver = Version::parse(active).unwrap_or_else(|e| { warn!("Unable to parse active web-vault version '{active}': {e}"); Version::parse("2025.1.1").unwrap() }); let latest_semver = Version::parse(latest).unwrap_or_else(|e| { warn!("Unable to parse latest web-vault version '{latest}': {e}"); Version::parse("2025.1.1").unwrap() }); match active_semver.cmp(&latest_semver) { Ordering::Less => -1, Ordering::Equal => 0, Ordering::Greater => 1, } } #[get("/diagnostics")] async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult> { use chrono::prelude::*; use std::net::ToSocketAddrs; // Execute some environment checks let running_within_container = is_running_in_container(); let has_http_access = has_http_access().await; let uses_proxy = env::var_os("HTTP_PROXY").is_some() || env::var_os("http_proxy").is_some() || env::var_os("HTTPS_PROXY").is_some() || env::var_os("https_proxy").is_some(); // Check if we are able to resolve DNS entries let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) { Ok(Some(a)) => a.ip().to_string(), _ => "Unable to resolve domain name.".to_string(), }; let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await; let active_web_release = get_active_web_release(); let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release); let ip_header_name = &ip_header.0.unwrap_or_default(); let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, "latest_release": latest_vw_release, "latest_commit": latest_vw_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), "active_web_release": active_web_release, "latest_web_release": latest_web_release, "web_vault_compare": web_vault_compare, "running_within_container": running_within_container, "container_base_image": if running_within_container { container_base_image() } else { "Not applicable" }, "has_http_access": has_http_access, "ip_header_exists": !ip_header_name.is_empty(), "ip_header_match": ip_header_name.eq(&CONFIG.ip_header()), "ip_header_name": ip_header_name, "ip_header_config": &CONFIG.ip_header(), "uses_proxy": uses_proxy, "enable_websocket": &CONFIG.enable_websocket(), "db_type": *DB_TYPE, "db_version": get_sql_server_version(&conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), "host_arch": env::consts::ARCH, "host_os": env::consts::OS, "tz_env": env::var("TZ").unwrap_or_default(), "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the server date/time check as late as possible to minimize the time difference "ntp_time": get_ntp_time(has_http_access).await, // Run the ntp check as late as possible to minimize the time difference }); let text = AdminTemplateData::new("admin/diagnostics", diagnostics_json).render()?; Ok(Html(text)) } #[get("/diagnostics/config", format = "application/json")] fn get_diagnostics_config(_token: AdminToken) -> Json { let support_json = CONFIG.get_support_json(); Json(support_json) } #[get("/diagnostics/http?")] fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult { err_code!(format!("Testing error {code} response"), code); } #[post("/config", format = "application/json", data = "")] async fn post_config(data: Json, _token: AdminToken) -> EmptyResult { let data: ConfigBuilder = data.into_inner(); if let Err(e) = CONFIG.update_config(data, true).await { err!(format!("Unable to save config: {e:?}")) } Ok(()) } #[post("/config/delete", format = "application/json")] async fn delete_config(_token: AdminToken) -> EmptyResult { if let Err(e) = CONFIG.delete_user_config().await { err!(format!("Unable to delete config: {e:?}")) } Ok(()) } #[post("/config/backup_db", format = "application/json")] fn backup_db(_token: AdminToken) -> ApiResult { if *CAN_BACKUP { match backup_sqlite() { Ok(f) => Ok(format!("Backup to '{f}' was successful")), Err(e) => err!(format!("Backup was unsuccessful {e}")), } } else { err!("Can't back up current DB (Only SQLite supports this feature)"); } } pub struct AdminToken { ip: ClientIp, } #[rocket::async_trait] impl<'r> FromRequest<'r> for AdminToken { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let ip = match ClientIp::from_request(request).await { Outcome::Success(ip) => ip, _ => err_handler!("Error getting Client IP"), }; if !CONFIG.disable_admin_token() { let cookies = request.cookies(); let access_token = match cookies.get(COOKIE_NAME) { Some(cookie) => cookie.value(), None => { let requested_page = request.segments::(0..).unwrap_or_default().display().to_string(); // When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page // Else, return a 401 failure, which will be caught if requested_page.is_empty() { return Outcome::Forward(Status::Unauthorized); } else { return Outcome::Error((Status::Unauthorized, "Unauthorized")); } } }; if decode_admin(access_token).is_err() { // Remove admin cookie cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); return Outcome::Error((Status::Unauthorized, "Session expired")); } } Outcome::Success(Self { ip, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn validate_web_vault_compare() { // web_vault_compare(active, latest) // Test normal versions assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1); assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0); assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1); // Test patched/+build.n versions // Newer latest version assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1); assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1); assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1); assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1); // Equal versions assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0); assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0); // Newer active version assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1); assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1); assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1); assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1); } } ================================================ FILE: src/api/core/accounts.rs ================================================ use std::collections::HashSet; use crate::db::DbPool; use chrono::Utc; use rocket::serde::json::Json; use serde_json::Value; use crate::{ api::{ core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, db::{ models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, }, DbConn, }, mail, util::{format_date, NumberOrString}, CONFIG, }; use rocket::{ http::Status, request::{FromRequest, Outcome, Request}, }; pub fn routes() -> Vec { routes![ profile, put_profile, post_profile, put_avatar, get_public_keys, post_keys, post_password, post_set_password, post_kdf, post_rotatekey, post_sstamp, post_email_token, post_email, post_verify_email, post_verify_email_token, post_delete_recover, post_delete_recover_token, post_delete_account, delete_account, revision_date, password_hint, prelogin, verify_password, api_key, rotate_api_key, get_known_device, get_all_devices, get_device, post_device_token, put_device_token, put_clear_device_token, post_clear_device_token, get_tasks, post_auth_request, get_auth_request, put_auth_request, get_auth_request_response, get_auth_requests, get_auth_requests_pending, ] } #[derive(Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KDFData { #[serde(alias = "kdfType")] kdf: i32, #[serde(alias = "iterations")] kdf_iterations: i32, #[serde(alias = "memory")] kdf_memory: Option, #[serde(alias = "parallelism")] kdf_parallelism: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterData { email: String, #[serde(flatten)] kdf: KDFData, #[serde(alias = "userSymmetricKey")] key: String, #[serde(alias = "userAsymmetricKeys")] keys: Option, master_password_hash: String, master_password_hint: Option, name: Option, #[allow(dead_code)] organization_user_id: Option, // Used only from the register/finish endpoint email_verification_token: Option, accept_emergency_access_id: Option, accept_emergency_access_invite_token: Option, #[serde(alias = "token")] org_invite_token: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { #[serde(flatten)] kdf: KDFData, key: String, keys: Option, master_password_hash: String, master_password_hint: Option, org_identifier: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KeysData { encrypted_private_key: String, public_key: String, } /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { None => None, Some(h) => match h.trim() { "" => None, ht => Some(ht.to_string()), }, } } fn enforce_password_hint_setting(password_hint: &Option) -> EmptyResult { if password_hint.is_some() && !CONFIG.password_hints_allowed() { err!("Password hints have been disabled by the administrator. Remove the hint and try again."); } Ok(()) } async fn is_email_2fa_required(member_id: Option, conn: &DbConn) -> bool { if !CONFIG._enable_email_2fa() { return false; } if CONFIG.email_2fa_enforce_on_verified_invite() { return true; } if let Some(member_id) = member_id { return OrgPolicy::is_enabled_for_member(&member_id, OrgPolicyType::TwoFactorAuthentication, conn).await; } false } pub async fn _register(data: Json, email_verification: bool, conn: DbConn) -> JsonResult { let mut data: RegisterData = data.into_inner(); let email = data.email.to_lowercase(); let mut email_verified = false; let mut pending_emergency_access = None; // First, validate the provided verification tokens if email_verification { match ( &data.email_verification_token, &data.accept_emergency_access_id, &data.accept_emergency_access_invite_token, &data.organization_user_id, &data.org_invite_token, ) { // Normal user registration, when email verification is required (Some(email_verification_token), None, None, None, None) => { let claims = crate::auth::decode_register_verify(email_verification_token)?; if claims.sub != data.email { err!("Email verification token does not match email"); } // During this call we don't get the name, so extract it from the claims if claims.name.is_some() { data.name = claims.name; } email_verified = claims.verified; } // Emergency access registration (None, Some(accept_emergency_access_id), Some(accept_emergency_access_invite_token), None, None) => { if !CONFIG.emergency_access_allowed() { err!("Emergency access is not enabled.") } let claims = crate::auth::decode_emergency_access_invite(accept_emergency_access_invite_token)?; if claims.email != data.email { err!("Claim email does not match email") } if &claims.emer_id != accept_emergency_access_id { err!("Claim emer_id does not match accept_emergency_access_id") } pending_emergency_access = Some((accept_emergency_access_id, claims)); email_verified = true; } // Org invite (None, None, None, Some(organization_user_id), Some(org_invite_token)) => { let claims = decode_invite(org_invite_token)?; if claims.email != data.email { err!("Claim email does not match email") } if &claims.member_id != organization_user_id { err!("Claim org_user_id does not match organization_user_id") } email_verified = true; } _ => { err!("Registration is missing required parameters") } } } // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // This also prevents issues with very long usernames causing to large JWT's. See #2419 if let Some(ref name) = data.name { if name.len() > 50 { err!("The field Name must be a string with a maximum length of 50."); } } // Check against the password hint setting here so if it fails, the user // can retry without losing their invitation below. let password_hint = clean_password_hint(&data.master_password_hint); enforce_password_hint_setting(&password_hint)?; let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") } if let Some(token) = data.org_invite_token { let claims = decode_invite(&token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token email_verified = true; user } else { err!("Registration email does not match invite email") } } else if Invitation::take(&email, &conn).await { Membership::accept_user_invitations(&user.uuid, &conn).await?; user } else if CONFIG.is_signup_allowed(&email) || (CONFIG.emergency_access_allowed() && EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some()) { user } else { err!("Registration not allowed or user already exists") } } None => { // Order is important here; the invitation check must come first // because the vaultwarden admin can invite anyone, regardless // of other signup restrictions. if Invitation::take(&email, &conn).await || CONFIG.is_signup_allowed(&email) || pending_emergency_access.is_some() { User::new(&email, None) } else { err!("Registration not allowed or user already exists") } } }; // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; set_kdf_data(&mut user, &data.kdf)?; user.set_password(&data.master_password_hash, Some(data.key), true, None); user.password_hint = password_hint; // Add extra fields if present if let Some(name) = data.name { user.name = name; } if let Some(keys) = data.keys { user.private_key = Some(keys.encrypted_private_key); user.public_key = Some(keys.public_key); } if email_verified { user.verified_at = Some(Utc::now().naive_utc()); } if CONFIG.mail_enabled() { if CONFIG.signups_verify() && !email_verified { if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await { error!("Error sending welcome email: {e:#?}"); } user.last_verifying_at = Some(user.created_at); } else if let Err(e) = mail::send_welcome(&user.email).await { error!("Error sending welcome email: {e:#?}"); } if email_verified && is_email_2fa_required(data.organization_user_id, &conn).await { email::activate_email_2fa(&user, &conn).await.ok(); } } user.save(&conn).await?; // accept any open emergency access invitations if !CONFIG.mail_enabled() && CONFIG.emergency_access_allowed() { for mut emergency_invite in EmergencyAccess::find_all_invited_by_grantee_email(&user.email, &conn).await { emergency_invite.accept_invite(&user.uuid, &user.email, &conn).await.ok(); } } Ok(Json(json!({ "object": "register", "captchaBypassToken": "", }))) } #[post("/accounts/set-password", data = "")] async fn post_set_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SetPasswordData = data.into_inner(); let mut user = headers.user; if user.private_key.is_some() { err!("Account already initialized, cannot set password") } // Check against the password hint setting here so if it fails, // the user can retry without losing their invitation below. let password_hint = clean_password_hint(&data.master_password_hint); enforce_password_hint_setting(&password_hint)?; set_kdf_data(&mut user, &data.kdf)?; user.set_password( &data.master_password_hash, Some(data.key), false, Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp ); user.password_hint = password_hint; if let Some(keys) = data.keys { user.private_key = Some(keys.encrypted_private_key); user.public_key = Some(keys.public_key); } if let Some(identifier) = data.org_identifier { if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID { let org = match Organization::find_by_uuid(&identifier.into(), &conn).await { None => err!("Failed to retrieve the associated organization"), Some(org) => org, }; let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await { None => err!("Failed to retrieve the invitation"), Some(org) => org, }; accept_org_invite(&user, membership, None, &conn).await?; } } if CONFIG.mail_enabled() { mail::send_welcome(&user.email.to_lowercase()).await?; } else { Membership::accept_user_invitations(&user.uuid, &conn).await?; } log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; user.save(&conn).await?; Ok(Json(json!({ "object": "set-password", "captchaBypassToken": "", }))) } #[get("/accounts/profile")] async fn profile(headers: Headers, conn: DbConn) -> Json { Json(headers.user.to_json(&conn).await) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ProfileData { // culture: String, // Ignored, always use en-US name: String, } #[put("/accounts/profile", data = "")] async fn put_profile(data: Json, headers: Headers, conn: DbConn) -> JsonResult { post_profile(data, headers, conn).await } #[post("/accounts/profile", data = "")] async fn post_profile(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: ProfileData = data.into_inner(); // Check if the length of the username exceeds 50 characters (Same is Upstream Bitwarden) // This also prevents issues with very long usernames causing to large JWT's. See #2419 if data.name.len() > 50 { err!("The field Name must be a string with a maximum length of 50."); } let mut user = headers.user; user.name = data.name; user.save(&conn).await?; Ok(Json(user.to_json(&conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AvatarData { avatar_color: Option, } #[put("/accounts/avatar", data = "")] async fn put_avatar(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: AvatarData = data.into_inner(); // It looks like it only supports the 6 hex color format. // If you try to add the short value it will not show that color. // Check and force 7 chars, including the #. if let Some(color) = &data.avatar_color { if color.len() != 7 { err!("The field AvatarColor must be a HTML/Hex color code with a length of 7 characters") } } let mut user = headers.user; user.avatar_color = data.avatar_color; user.save(&conn).await?; Ok(Json(user.to_json(&conn).await)) } #[get("/users//public-key")] async fn get_public_keys(user_id: UserId, _headers: Headers, conn: DbConn) -> JsonResult { let user = match User::find_by_uuid(&user_id, &conn).await { Some(user) if user.public_key.is_some() => user, Some(_) => err_code!("User has no public_key", Status::NotFound.code), None => err_code!("User doesn't exist", Status::NotFound.code), }; Ok(Json(json!({ "userId": user.uuid, "publicKey": user.public_key, "object":"userKey" }))) } #[post("/accounts/keys", data = "")] async fn post_keys(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: KeysData = data.into_inner(); let mut user = headers.user; user.private_key = Some(data.encrypted_private_key); user.public_key = Some(data.public_key); user.save(&conn).await?; Ok(Json(json!({ "privateKey": user.private_key, "publicKey": user.public_key, "object":"keys" }))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangePassData { master_password_hash: String, new_master_password_hash: String, master_password_hint: Option, key: String, } #[post("/accounts/password", data = "")] async fn post_password(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: ChangePassData = data.into_inner(); let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } user.password_hint = clean_password_hint(&data.master_password_hint); enforce_password_hint_setting(&user.password_hint)?; log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; user.set_password( &data.new_master_password_hash, Some(data.key), true, Some(vec![ String::from("post_rotatekey"), String::from("get_contacts"), String::from("get_public_keys"), String::from("get_api_webauthn"), ]), ); let save_result = user.save(&conn).await; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; save_result } fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { if data.kdf == UserKdfType::Pbkdf2 as i32 && data.kdf_iterations < 100_000 { err!("PBKDF2 KDF iterations must be at least 100000.") } if data.kdf == UserKdfType::Argon2id as i32 { if data.kdf_iterations < 1 { err!("Argon2 KDF iterations must be at least 1.") } if let Some(m) = data.kdf_memory { if !(15..=1024).contains(&m) { err!("Argon2 memory must be between 15 MB and 1024 MB.") } user.client_kdf_memory = data.kdf_memory; } else { err!("Argon2 memory parameter is required.") } if let Some(p) = data.kdf_parallelism { if !(1..=16).contains(&p) { err!("Argon2 parallelism must be between 1 and 16.") } user.client_kdf_parallelism = data.kdf_parallelism; } else { err!("Argon2 parallelism parameter is required.") } } else { user.client_kdf_memory = None; user.client_kdf_parallelism = None; } user.client_kdf_iter = data.kdf_iterations; user.client_kdf_type = data.kdf; Ok(()) } #[allow(dead_code)] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AuthenticationData { salt: String, kdf: KDFData, master_password_authentication_hash: String, } #[allow(dead_code)] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UnlockData { salt: String, kdf: KDFData, master_key_wrapped_user_key: String, } #[allow(dead_code)] #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangeKdfData { new_master_password_hash: String, key: String, authentication_data: AuthenticationData, unlock_data: UnlockData, master_password_hash: String, } #[post("/accounts/kdf", data = "")] async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: ChangeKdfData = data.into_inner(); if !headers.user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } if data.authentication_data.kdf != data.unlock_data.kdf { err!("KDF settings must be equal for authentication and unlock") } if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { err!("Invalid master password salt") } let mut user = headers.user; set_kdf_data(&mut user, &data.unlock_data.kdf)?; user.set_password( &data.authentication_data.master_password_authentication_hash, Some(data.unlock_data.master_key_wrapped_user_key), true, None, ); let save_result = user.save(&conn).await; nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; save_result } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateFolderData { // There is a bug in 2024.3.x which adds a `null` item. // To bypass this we allow a Option here, but skip it during the updates // See: https://github.com/bitwarden/clients/issues/8453 id: Option, name: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateEmergencyAccessData { id: EmergencyAccessId, key_encrypted: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateResetPasswordData { organization_id: OrganizationId, reset_password_key: String, } use super::ciphers::CipherData; use super::sends::{update_send_from_data, SendData}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct KeyData { account_unlock_data: RotateAccountUnlockData, account_keys: RotateAccountKeys, account_data: RotateAccountData, old_master_key_authentication_hash: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountUnlockData { emergency_access_unlock_data: Vec, master_password_unlock_data: MasterPasswordUnlockData, organization_account_recovery_unlock_data: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MasterPasswordUnlockData { kdf_type: i32, kdf_iterations: i32, kdf_parallelism: Option, kdf_memory: Option, email: String, master_key_authentication_hash: String, master_key_encrypted_user_key: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountKeys { user_key_encrypted_account_private_key: String, account_public_key: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RotateAccountData { ciphers: Vec, folders: Vec, sends: Vec, } fn validate_keydata( data: &KeyData, existing_ciphers: &[Cipher], existing_folders: &[Folder], existing_emergency_access: &[EmergencyAccess], existing_memberships: &[Membership], existing_sends: &[Send], user: &User, ) -> EmptyResult { if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type || user.client_kdf_iter != data.account_unlock_data.master_password_unlock_data.kdf_iterations || user.client_kdf_memory != data.account_unlock_data.master_password_unlock_data.kdf_memory || user.client_kdf_parallelism != data.account_unlock_data.master_password_unlock_data.kdf_parallelism || user.email != data.account_unlock_data.master_password_unlock_data.email { err!("Changing the kdf variant or email is not supported during key rotation"); } if user.public_key.as_ref() != Some(&data.account_keys.account_public_key) { err!("Changing the asymmetric keypair is not possible during key rotation") } // Check that we're correctly rotating all the user's ciphers let existing_cipher_ids = existing_ciphers.iter().map(|c| &c.uuid).collect::>(); let provided_cipher_ids = data .account_data .ciphers .iter() .filter(|c| c.organization_id.is_none()) .filter_map(|c| c.id.as_ref()) .collect::>(); if !provided_cipher_ids.is_superset(&existing_cipher_ids) { err!("All existing ciphers must be included in the rotation") } // Check that we're correctly rotating all the user's folders let existing_folder_ids = existing_folders.iter().map(|f| &f.uuid).collect::>(); let provided_folder_ids = data.account_data.folders.iter().filter_map(|f| f.id.as_ref()).collect::>(); if !provided_folder_ids.is_superset(&existing_folder_ids) { err!("All existing folders must be included in the rotation") } // Check that we're correctly rotating all the user's emergency access keys let existing_emergency_access_ids = existing_emergency_access.iter().map(|ea| &ea.uuid).collect::>(); let provided_emergency_access_ids = data .account_unlock_data .emergency_access_unlock_data .iter() .map(|ea| &ea.id) .collect::>(); if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) { err!("All existing emergency access keys must be included in the rotation") } // Check that we're correctly rotating all the user's reset password keys let existing_reset_password_ids = existing_memberships.iter().map(|m| &m.org_uuid).collect::>(); let provided_reset_password_ids = data .account_unlock_data .organization_account_recovery_unlock_data .iter() .map(|rp| &rp.organization_id) .collect::>(); if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) { err!("All existing reset password keys must be included in the rotation") } // Check that we're correctly rotating all the user's sends let existing_send_ids = existing_sends.iter().map(|s| &s.uuid).collect::>(); let provided_send_ids = data.account_data.sends.iter().filter_map(|s| s.id.as_ref()).collect::>(); if !provided_send_ids.is_superset(&existing_send_ids) { err!("All existing sends must be included in the rotation") } Ok(()) } #[post("/accounts/key-management/rotate-user-account-keys", data = "")] async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. let data: KeyData = data.into_inner(); if !headers.user.check_valid_password(&data.old_master_key_authentication_hash) { err!("Invalid password") } // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.account_data.ciphers)?; let user_id = &headers.user.uuid; // TODO: Ideally we'd do everything after this point in a single transaction. let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await; let mut existing_folders = Folder::find_by_user(user_id, &conn).await; let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await; let mut existing_memberships = Membership::find_by_user(user_id, &conn).await; // We only rotate the reset password key if it is set. existing_memberships.retain(|m| m.reset_password_key.is_some()); let mut existing_sends = Send::find_by_user(user_id, &conn).await; validate_keydata( &data, &existing_ciphers, &existing_folders, &existing_emergency_access, &existing_memberships, &existing_sends, &headers.user, )?; // Update folder data for folder_data in data.account_data.folders { // Skip `null` folder id entries. // See: https://github.com/bitwarden/clients/issues/8453 if let Some(folder_id) = folder_data.id { let Some(saved_folder) = existing_folders.iter_mut().find(|f| f.uuid == folder_id) else { err!("Folder doesn't exist") }; saved_folder.name = folder_data.name; saved_folder.save(&conn).await? } } // Update emergency access data for emergency_access_data in data.account_unlock_data.emergency_access_unlock_data { let Some(saved_emergency_access) = existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) else { err!("Emergency access doesn't exist or is not owned by the user") }; saved_emergency_access.key_encrypted = Some(emergency_access_data.key_encrypted); saved_emergency_access.save(&conn).await? } // Update reset password data for reset_password_data in data.account_unlock_data.organization_account_recovery_unlock_data { let Some(membership) = existing_memberships.iter_mut().find(|m| m.org_uuid == reset_password_data.organization_id) else { err!("Reset password doesn't exist") }; membership.reset_password_key = Some(reset_password_data.reset_password_key); membership.save(&conn).await? } // Update send data for send_data in data.account_data.sends { let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else { err!("Send doesn't exist") }; update_send_from_data(send, send_data, &headers, &conn, &nt, UpdateType::None).await?; } // Update cipher data use super::ciphers::update_cipher_from_data; for cipher_data in data.account_data.ciphers { if cipher_data.organization_id.is_none() { let Some(saved_cipher) = existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) else { err!("Cipher doesn't exist") }; // Prevent triggering cipher updates via WebSockets by settings UpdateType::None // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // We force the users to logout after the user has been saved to try and prevent these issues. update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await? } } // Update user data let mut user = headers.user; user.private_key = Some(data.account_keys.user_key_encrypted_account_private_key); user.set_password( &data.account_unlock_data.master_password_unlock_data.master_key_authentication_hash, Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), true, None, ); let save_result = user.save(&conn).await; // Prevent logging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; save_result } #[post("/accounts/security-stamp", data = "")] async fn post_sstamp(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; data.validate(&user, true, &conn).await?; Device::delete_all_by_user(&user.uuid, &conn).await?; user.reset_security_stamp(); let save_result = user.save(&conn).await; nt.send_logout(&user, None, &conn).await; save_result } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmailTokenData { master_password_hash: String, new_email: String, } #[post("/accounts/email-token", data = "")] async fn post_email_token(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.email_change_allowed() { err!("Email change is not allowed."); } let data: EmailTokenData = data.into_inner(); let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } if let Some(existing_user) = User::find_by_mail(&data.new_email, &conn).await { if CONFIG.mail_enabled() { // check if existing_user has already registered if existing_user.password_hash.is_empty() { // inform an invited user about how to delete their temporary account if the // request was done intentionally and they want to update their mail address if let Err(e) = mail::send_change_email_invited(&data.new_email, &user.email).await { error!("Error sending change-email-invited email: {e:#?}"); } } else { // inform existing user about the failed attempt to change their mail address if let Err(e) = mail::send_change_email_existing(&data.new_email, &user.email).await { error!("Error sending change-email-existing email: {e:#?}"); } } } err!("Email already in use"); } if !CONFIG.is_email_domain_allowed(&data.new_email) { err!("Email domain not allowed"); } let token = crypto::generate_email_token(6); if CONFIG.mail_enabled() { if let Err(e) = mail::send_change_email(&data.new_email, &token).await { error!("Error sending change-email email: {e:#?}"); } } else { debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email); } user.email_new = Some(data.new_email); user.email_new_token = Some(token); user.save(&conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangeEmailData { master_password_hash: String, new_email: String, key: String, new_master_password_hash: String, token: NumberOrString, } #[post("/accounts/email", data = "")] async fn post_email(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { if !CONFIG.email_change_allowed() { err!("Email change is not allowed."); } let data: ChangeEmailData = data.into_inner(); let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } if User::find_by_mail(&data.new_email, &conn).await.is_some() { err!("Email already in use"); } match user.email_new { Some(ref val) => { if val != &data.new_email { err!("Email change mismatch"); } } None => err!("No email change pending"), } if CONFIG.mail_enabled() { // Only check the token if we sent out an email... match user.email_new_token { Some(ref val) => { if *val != data.token.into_string() { err!("Token mismatch"); } } None => err!("No email change pending"), } user.verified_at = Some(Utc::now().naive_utc()); } else { user.verified_at = None; } user.email = data.new_email; user.email_new = None; user.email_new_token = None; user.set_password(&data.new_master_password_hash, Some(data.key), true, None); let save_result = user.save(&conn).await; nt.send_logout(&user, None, &conn).await; save_result } #[post("/accounts/verify-email")] async fn post_verify_email(headers: Headers) -> EmptyResult { let user = headers.user; if !CONFIG.mail_enabled() { err!("Cannot verify email address"); } if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await { error!("Error sending verify_email email: {e:#?}"); } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct VerifyEmailTokenData { user_id: UserId, token: String, } #[post("/accounts/verify-email-token", data = "")] async fn post_verify_email_token(data: Json, conn: DbConn) -> EmptyResult { let data: VerifyEmailTokenData = data.into_inner(); let Some(mut user) = User::find_by_uuid(&data.user_id, &conn).await else { err!("User doesn't exist") }; let Ok(claims) = decode_verify_email(&data.token) else { err!("Invalid claim") }; if claims.sub != *user.uuid { err!("Invalid claim"); } user.verified_at = Some(Utc::now().naive_utc()); user.last_verifying_at = None; user.login_verify_count = 0; if let Err(e) = user.save(&conn).await { error!("Error saving email verification: {e:#?}"); } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteRecoverData { email: String, } #[post("/accounts/delete-recover", data = "")] async fn post_delete_recover(data: Json, conn: DbConn) -> EmptyResult { let data: DeleteRecoverData = data.into_inner(); if CONFIG.mail_enabled() { if let Some(user) = User::find_by_mail(&data.email, &conn).await { if let Err(e) = mail::send_delete_account(&user.email, &user.uuid).await { error!("Error sending delete account email: {e:#?}"); } } Ok(()) } else { // We don't support sending emails, but we shouldn't allow anybody // to delete accounts without at least logging in... And if the user // cannot remember their password then they will need to contact // the administrator to delete it... err!("Please contact the administrator to delete your account"); } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteRecoverTokenData { user_id: UserId, token: String, } #[post("/accounts/delete-recover-token", data = "")] async fn post_delete_recover_token(data: Json, conn: DbConn) -> EmptyResult { let data: DeleteRecoverTokenData = data.into_inner(); let Ok(claims) = decode_delete(&data.token) else { err!("Invalid claim") }; let Some(user) = User::find_by_uuid(&data.user_id, &conn).await else { err!("User doesn't exist") }; if claims.sub != *user.uuid { err!("Invalid claim"); } user.delete(&conn).await } #[post("/accounts/delete", data = "")] async fn post_delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { delete_account(data, headers, conn).await } #[delete("/accounts", data = "")] async fn delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, true, &conn).await?; user.delete(&conn).await } #[get("/accounts/revision-date")] fn revision_date(headers: Headers) -> JsonResult { let revision_date = headers.user.updated_at.and_utc().timestamp_millis(); Ok(Json(json!(revision_date))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordHintData { email: String, } #[post("/accounts/password-hint", data = "")] async fn password_hint(data: Json, conn: DbConn) -> EmptyResult { if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) { err!("This server is not configured to provide password hints."); } const NO_HINT: &str = "Sorry, you have no password hint..."; let data: PasswordHintData = data.into_inner(); let email = &data.email; match User::find_by_mail(email, &conn).await { None => { // To prevent user enumeration, act as if the user exists. if CONFIG.mail_enabled() { // There is still a timing side channel here in that the code // paths that send mail take noticeably longer than ones that // don't. Add a randomized sleep to mitigate this somewhat. use rand::{rngs::SmallRng, RngExt}; let mut rng: SmallRng = rand::make_rng(); let sleep_ms = rng.random_range(900..=1100) as u64; tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; Ok(()) } else { err!(NO_HINT); } } Some(user) => { let hint: Option = user.password_hint; if CONFIG.mail_enabled() { mail::send_password_hint(email, hint).await?; Ok(()) } else if let Some(hint) = hint { err!(format!("Your password hint is: {hint}")); } else { err!(NO_HINT); } } } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreloginData { email: String, } #[post("/accounts/prelogin", data = "")] async fn prelogin(data: Json, conn: DbConn) -> Json { _prelogin(data, conn).await } pub async fn _prelogin(data: Json, conn: DbConn) -> Json { let data: PreloginData = data.into_inner(); let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { Some(user) => (user.client_kdf_type, user.client_kdf_iter, user.client_kdf_memory, user.client_kdf_parallelism), None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), }; Json(json!({ "kdf": kdf_type, "kdfIterations": kdf_iter, "kdfMemory": kdf_mem, "kdfParallelism": kdf_para, })) } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SecretVerificationRequest { master_password_hash: String, } // Change the KDF Iterations if necessary pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> { if user.password_iterations < CONFIG.password_iterations() { user.password_iterations = CONFIG.password_iterations(); user.set_password(pwd_hash, None, false, None); if let Err(e) = user.save(conn).await { error!("Error updating user: {e:#?}"); } } Ok(()) } #[post("/accounts/verify-password", data = "")] async fn verify_password(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); let mut user = headers.user; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password") } kdf_upgrade(&mut user, &data.master_password_hash, &conn).await?; Ok(Json(master_password_policy(&user, &conn).await)) } async fn _api_key(data: Json, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { use crate::util::format_date; let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; data.validate(&user, true, &conn).await?; if rotate || user.api_key.is_none() { user.api_key = Some(crypto::generate_api_key()); user.save(&conn).await.expect("Error saving API key"); } Ok(Json(json!({ "apiKey": user.api_key, "revisionDate": format_date(&user.updated_at), "object": "apiKey", }))) } #[post("/accounts/api-key", data = "")] async fn api_key(data: Json, headers: Headers, conn: DbConn) -> JsonResult { _api_key(data, false, headers, conn).await } #[post("/accounts/rotate-api-key", data = "")] async fn rotate_api_key(data: Json, headers: Headers, conn: DbConn) -> JsonResult { _api_key(data, true, headers, conn).await } #[get("/devices/knowndevice")] async fn get_known_device(device: KnownDevice, conn: DbConn) -> JsonResult { let result = if let Some(user) = User::find_by_mail(&device.email, &conn).await { Device::find_by_uuid_and_user(&device.uuid, &user.uuid, &conn).await.is_some() } else { false }; Ok(Json(json!(result))) } struct KnownDevice { email: String, uuid: DeviceId, } #[rocket::async_trait] impl<'r> FromRequest<'r> for KnownDevice { type Error = &'static str; async fn from_request(req: &'r Request<'_>) -> Outcome { let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") { // Bitwarden seems to send padded Base64 strings since 2026.2.1 // Since these values are not streamed and Headers are always split by newlines // we can safely ignore padding here and remove any '=' appended. let email_b64 = email_b64.trim_end_matches('='); let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else { return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url")); }; match String::from_utf8(email_bytes) { Ok(email) => email, Err(_) => { return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as UTF-8")); } } } else { return Outcome::Error((Status::BadRequest, "X-Request-Email value is required")); }; let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") { uuid.to_string().into() } else { return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required")); }; Outcome::Success(KnownDevice { email, uuid, }) } } #[get("/devices")] async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult { let devices = Device::find_with_auth_request_by_user(&headers.user.uuid, &conn).await; let devices = devices.iter().map(|device| device.to_json()).collect::>(); Ok(Json(json!({ "data": devices, "continuationToken": null, "object": "list" }))) } #[get("/devices/identifier/")] async fn get_device(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult { let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else { err!("No device found"); }; Ok(Json(device.to_json())) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PushToken { push_token: String, } #[post("/devices/identifier//token", data = "")] async fn post_device_token(device_id: DeviceId, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { put_device_token(device_id, data, headers, conn).await } #[put("/devices/identifier//token", data = "")] async fn put_device_token(device_id: DeviceId, data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data = data.into_inner(); let token = data.push_token; let Some(mut device) = Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &conn).await else { err!(format!("Error: device {device_id} should be present before a token can be assigned")) }; // Check if the new token is the same as the registered token // Although upstream seems to always register a device on login, we do not. // Unless this causes issues, lets keep it this way, else we might need to also register on every login. if device.push_token.as_ref() == Some(&token) { debug!("Device {device_id} for user {} is already registered and token is identical", headers.user.uuid); return Ok(()); } device.push_token = Some(token); if let Err(e) = device.save(true, &conn).await { err!(format!("An error occurred while trying to save the device push token: {e}")); } register_push_device(&mut device, &conn).await?; Ok(()) } #[put("/devices/identifier//clear-token")] async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { // This only clears push token // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37 // This is somehow not implemented in any app, added it in case it is required // 2025: Also, it looks like it only clears the first found device upstream, which is probably faulty. // This because currently multiple accounts could be on the same device/app and that would cause issues. // Vaultwarden removes the push-token for all devices, but this probably means we should also unregister all these devices. if !CONFIG.push_enabled() { return Ok(()); } if let Some(device) = Device::find_by_uuid(&device_id, &conn).await { Device::clear_push_token_by_uuid(&device_id, &conn).await?; unregister_push_device(&device.push_uuid).await?; } Ok(()) } // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere #[post("/devices/identifier//clear-token")] async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { put_clear_device_token(device_id, conn).await } #[get("/tasks")] fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { Ok(Json(json!({ "data": [], "object": "list" }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthRequestRequest { access_code: String, device_identifier: DeviceId, email: String, public_key: String, // Not used for now // #[serde(alias = "type")] // _type: i32, } #[post("/auth-requests", data = "")] async fn post_auth_request( data: Json, client_headers: ClientHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data = data.into_inner(); let Some(user) = User::find_by_mail(&data.email, &conn).await else { err!("AuthRequest doesn't exist", "User not found") }; // Validate device uuid and type let device = match Device::find_by_uuid_and_user(&data.device_identifier, &user.uuid, &conn).await { Some(device) if device.atype == client_headers.device_type => device, _ => err!("AuthRequest doesn't exist", "Device verification failed"), }; let mut auth_request = AuthRequest::new( user.uuid.clone(), data.device_identifier.clone(), client_headers.device_type, client_headers.ip.ip.to_string(), data.access_code, data.public_key, ); auth_request.save(&conn).await?; nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( EventType::UserRequestedDeviceApproval as i32, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn, ) .await; Ok(Json(json!({ "id": auth_request.uuid, "publicKey": auth_request.public_key, "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), "requestIpAddress": auth_request.request_ip, "key": null, "masterPasswordHash": null, "creationDate": format_date(&auth_request.creation_date), "responseDate": null, "requestApproved": false, "origin": CONFIG.domain_origin(), "object": "auth-request" }))) } #[get("/auth-requests/")] async fn get_auth_request(auth_request_id: AuthRequestId, headers: Headers, conn: DbConn) -> JsonResult { let Some(auth_request) = AuthRequest::find_by_uuid_and_user(&auth_request_id, &headers.user.uuid, &conn).await else { err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") }; let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); Ok(Json(json!({ "id": &auth_request_id, "publicKey": auth_request.public_key, "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), "requestIpAddress": auth_request.request_ip, "key": auth_request.enc_key, "masterPasswordHash": auth_request.master_password_hash, "creationDate": format_date(&auth_request.creation_date), "responseDate": response_date_utc, "requestApproved": auth_request.approved, "origin": CONFIG.domain_origin(), "object":"auth-request" }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthResponseRequest { device_identifier: DeviceId, key: String, master_password_hash: Option, request_approved: bool, } #[put("/auth-requests/", data = "")] async fn put_auth_request( auth_request_id: AuthRequestId, data: Json, headers: Headers, conn: DbConn, ant: AnonymousNotify<'_>, nt: Notify<'_>, ) -> JsonResult { let data = data.into_inner(); let Some(mut auth_request) = AuthRequest::find_by_uuid_and_user(&auth_request_id, &headers.user.uuid, &conn).await else { err!("AuthRequest doesn't exist", "Record not found or user uuid does not match") }; if headers.device.uuid != data.device_identifier { err!("AuthRequest doesn't exist", "Device verification failed") } if auth_request.approved.is_some() { err!("An authentication request with the same device already exists") } let response_date = Utc::now().naive_utc(); let response_date_utc = format_date(&response_date); if data.request_approved { auth_request.approved = Some(data.request_approved); auth_request.enc_key = Some(data.key); auth_request.master_password_hash = data.master_password_hash; auth_request.response_device_id = Some(data.device_identifier.clone()); auth_request.response_date = Some(response_date); auth_request.save(&conn).await?; ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await; nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; log_user_event( EventType::OrganizationUserApprovedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; } else { // If denied, there's no reason to keep the request auth_request.delete(&conn).await?; log_user_event( EventType::OrganizationUserRejectedAuthRequest as i32, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; } Ok(Json(json!({ "id": &auth_request_id, "publicKey": auth_request.public_key, "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), "requestIpAddress": auth_request.request_ip, "key": auth_request.enc_key, "masterPasswordHash": auth_request.master_password_hash, "creationDate": format_date(&auth_request.creation_date), "responseDate": response_date_utc, "requestApproved": auth_request.approved, "origin": CONFIG.domain_origin(), "object":"auth-request" }))) } #[get("/auth-requests//response?")] async fn get_auth_request_response( auth_request_id: AuthRequestId, code: &str, client_headers: ClientHeaders, conn: DbConn, ) -> JsonResult { let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else { err!("AuthRequest doesn't exist", "User not found") }; if auth_request.device_type != client_headers.device_type || auth_request.request_ip != client_headers.ip.ip.to_string() || !auth_request.check_access_code(code) { err!("AuthRequest doesn't exist", "Invalid device, IP or code") } let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); Ok(Json(json!({ "id": &auth_request_id, "publicKey": auth_request.public_key, "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), "requestIpAddress": auth_request.request_ip, "key": auth_request.enc_key, "masterPasswordHash": auth_request.master_password_hash, "creationDate": format_date(&auth_request.creation_date), "responseDate": response_date_utc, "requestApproved": auth_request.approved, "origin": CONFIG.domain_origin(), "object":"auth-request" }))) } // Now unused but not yet removed // cf https://github.com/bitwarden/clients/blob/9b2fbdba1c028bf3394064609630d2ec224baefa/libs/common/src/services/api.service.ts#L245 #[get("/auth-requests")] async fn get_auth_requests(headers: Headers, conn: DbConn) -> JsonResult { get_auth_requests_pending(headers, conn).await } #[get("/auth-requests/pending")] async fn get_auth_requests_pending(headers: Headers, conn: DbConn) -> JsonResult { let auth_requests = AuthRequest::find_by_user(&headers.user.uuid, &conn).await; Ok(Json(json!({ "data": auth_requests .iter() .filter(|request| request.approved.is_none()) .map(|request| { let response_date_utc = request.response_date.map(|response_date| format_date(&response_date)); json!({ "id": request.uuid, "publicKey": request.public_key, "requestDeviceType": DeviceType::from_i32(request.device_type).to_string(), "requestIpAddress": request.request_ip, "key": request.enc_key, "masterPasswordHash": request.master_password_hash, "creationDate": format_date(&request.creation_date), "responseDate": response_date_utc, "requestApproved": request.approved, "origin": CONFIG.domain_origin(), "object":"auth-request" }) }).collect::>(), "continuationToken": null, "object": "list" }))) } pub async fn purge_auth_requests(pool: DbPool) { debug!("Purging auth requests"); if let Ok(conn) = pool.get().await { AuthRequest::purge_expired_auth_requests(&conn).await; } else { error!("Failed to get DB connection while purging auth requests") } } ================================================ FILE: src/api/core/ciphers.rs ================================================ use std::collections::{HashMap, HashSet}; use chrono::{NaiveDateTime, Utc}; use num_traits::ToPrimitive; use rocket::fs::TempFile; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, Route, }; use serde_json::Value; use crate::auth::ClientVersion; use crate::util::{save_temp_file, NumberOrString}; use crate::{ api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, auth::Headers, config::PathType, crypto, db::{ models::{ Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, DbConn, DbPool, }, CONFIG, }; use super::folders::FolderData; pub fn routes() -> Vec { // Note that many routes have an `admin` variant; this seems to be // because the stored procedure that upstream Bitwarden uses to determine // whether the user can edit a cipher doesn't take into account whether // the user is an org owner/admin. The `admin` variant first checks // whether the user is an owner/admin of the relevant org, and if so, // allows the operation unconditionally. // // vaultwarden factors in the org owner/admin status as part of // determining the write accessibility of a cipher, so most // admin/non-admin implementations can be shared. routes![ sync, get_ciphers, get_cipher, get_cipher_admin, get_cipher_details, post_ciphers, put_cipher_admin, post_ciphers_admin, post_ciphers_create, post_ciphers_import, get_attachment, post_attachment_v2, post_attachment_v2_data, post_attachment, // legacy post_attachment_admin, // legacy post_attachment_share, delete_attachment_post, delete_attachment_post_admin, delete_attachment, delete_attachment_admin, post_cipher_admin, post_cipher_share, put_cipher_share, put_cipher_share_selected, post_cipher, post_cipher_partial, put_cipher, put_cipher_partial, 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, delete_cipher_selected_admin, delete_cipher_selected_post_admin, delete_cipher_selected_put_admin, restore_cipher_put, restore_cipher_put_admin, restore_cipher_selected, restore_cipher_selected_admin, delete_all, move_cipher_selected, move_cipher_selected_put, put_collections2_update, post_collections2_update, put_collections_update, post_collections_update, post_collections_admin, put_collections_admin, ] } pub async fn purge_trashed_ciphers(pool: DbPool) { debug!("Purging trashed ciphers"); if let Ok(conn) = pool.get().await { Cipher::purge_trash(&conn).await; } else { error!("Failed to get DB connection while purging trashed ciphers") } } #[derive(FromForm, Default)] struct SyncData { #[field(name = "excludeDomains")] exclude_domains: bool, // Default: 'false' } #[get("/sync?")] async fn sync(data: SyncData, headers: Headers, client_version: Option, conn: DbConn) -> JsonResult { let user_json = headers.user.to_json(&conn).await; // Get all ciphers which are visible by the user let mut ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; // Filter out SSH keys if the client version is less than 2024.12.0 let show_ssh_keys = if let Some(client_version) = client_version { let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap(); ver_match.matches(&client_version.0) } else { false }; if !show_ssh_keys { ciphers.retain(|c| c.atype != 5); } let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; // Lets generate the ciphers_json using all the gathered info let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push( c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn).await?, ); } let collections = Collection::find_by_user_uuid(headers.user.uuid.clone(), &conn).await; let mut collections_json = Vec::with_capacity(collections.len()); for c in collections { collections_json.push(c.to_json_details(&headers.user.uuid, Some(&cipher_sync_data), &conn).await); } let folders_json: Vec = Folder::find_by_user(&headers.user.uuid, &conn).await.iter().map(Folder::to_json).collect(); let sends_json: Vec = Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); let policies_json: Vec = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); let domains_json = if data.exclude_domains { Value::Null } else { api::core::_get_eq_domains(&headers, true).into_inner() }; // This is very similar to the the userDecryptionOptions sent in connect/token, // but as of 2025-12-19 they're both using different casing conventions. let has_master_password = !headers.user.password_hash.is_empty(); let master_password_unlock = if has_master_password { json!({ "kdf": { "kdfType": headers.user.client_kdf_type, "iterations": headers.user.client_kdf_iter, "memory": headers.user.client_kdf_memory, "parallelism": headers.user.client_kdf_parallelism }, // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "masterKeyEncryptedUserKey": headers.user.akey, "masterKeyWrappedUserKey": headers.user.akey, "salt": headers.user.email }) } else { Value::Null }; Ok(Json(json!({ "profile": user_json, "folders": folders_json, "collections": collections_json, "policies": policies_json, "ciphers": ciphers_json, "domains": domains_json, "sends": sends_json, "userDecryption": { "masterPasswordUnlock": master_password_unlock, }, "object": "sync" }))) } #[get("/ciphers")] async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &conn).await; let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push( c.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn).await?, ); } Ok(Json(json!({ "data": ciphers_json, "object": "list", "continuationToken": null }))) } #[get("/ciphers/")] async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not owned by user") } Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[get("/ciphers//admin")] async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { // TODO: Implement this correctly get_cipher(cipher_id, headers, conn).await } #[get("/ciphers//details")] async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { get_cipher(cipher_id, headers, conn).await } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { // Id is optional as it is included only in bulk share pub id: Option, // Folder id is not included in import pub folder_id: Option, // TODO: Some of these might appear all the time, no need for Option #[serde(alias = "organizationID")] pub organization_id: Option, key: Option, /* Login = 1, SecureNote = 2, Card = 3, Identity = 4, SshKey = 5 */ pub r#type: i32, pub name: String, pub notes: Option, fields: Option, // Only one of these should exist, depending on type login: Option, secure_note: Option, card: Option, identity: Option, ssh_key: Option, favorite: Option, reprompt: Option, pub password_history: Option, // These are used during key rotation // 'Attachments' is unused, contains map of {id: filename} #[allow(dead_code)] attachments: Option, attachments2: Option>, // The revision datetime (in ISO 8601 format) of the client's local copy // of the cipher. This is used to prevent a client from updating a cipher // when it doesn't have the latest version, as that can result in data // loss. It's not an error when no value is provided; this can happen // when using older client versions, or if the operation doesn't involve // updating an existing cipher. last_known_revision_date: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PartialCipherData { folder_id: Option, favorite: bool, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachments2Data { file_name: String, key: String, } /// Called when an org admin clones an org cipher. #[post("/ciphers/admin", data = "")] async fn post_ciphers_admin(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { post_ciphers_create(data, headers, conn, nt).await } /// Called when creating a new org-owned cipher, or cloning a cipher (whether /// user- or org-owned). When cloning a cipher to a user-owned cipher, /// `organizationId` is null. #[post("/ciphers/create", data = "")] async fn post_ciphers_create( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); // This check is usually only needed in update_cipher_from_data(), but we // need it here as well to avoid creating an empty cipher in the call to // cipher.save() below. enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?; let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone()); cipher.user_uuid = Some(headers.user.uuid.clone()); cipher.save(&conn).await?; // When cloning a cipher, the Bitwarden clients seem to set this field // based on the cipher being cloned (when creating a new cipher, it's set // to null as expected). However, `cipher.created_at` is initialized to // the current time, so the stale data check will end up failing down the // line. Since this function only creates new ciphers (whether by cloning // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; if res.is_err() { cipher.delete(&conn).await?; } res } /// Called when creating a new user-owned cipher. #[post("/ciphers", data = "")] async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let mut data: CipherData = data.into_inner(); // The web/browser clients set this field to null as expected, but the // mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // which results in a warning message being logged. This field isn't // needed when creating a new cipher, so just ignore it unconditionally. data.last_known_revision_date = None; let mut cipher = Cipher::new(data.r#type, data.name.clone()); update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } /// Enforces the personal ownership policy on user-owned ciphers, if applicable. /// A non-owner/admin user belonging to an org with the personal ownership policy /// enabled isn't allowed to create new user-owned ciphers or modify existing ones /// (that were created before the policy was applicable to the user). The user is /// allowed to delete or share such ciphers to an org, however. /// /// Ref: https://bitwarden.com/help/article/policies/#personal-ownership async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &Headers, conn: &DbConn) -> EmptyResult { if data.is_none() || data.unwrap().organization_id.is_none() { let user_id = &headers.user.uuid; let policy_type = OrgPolicyType::PersonalOwnership; if OrgPolicy::is_applicable_to_user(user_id, policy_type, None, conn).await { err!("Due to an Enterprise Policy, you are restricted from saving items to your personal vault.") } } Ok(()) } pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, headers: &Headers, shared_to_collections: Option>, conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { enforce_personal_ownership_policy(Some(&data), headers, conn).await?; // Check that the client isn't updating an existing cipher with stale data. // And only perform this check when not importing ciphers, else the date/time check will fail. if ut != UpdateType::None { if let Some(dt) = data.last_known_revision_date { match NaiveDateTime::parse_from_str(&dt, "%+") { // ISO 8601 format Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"), Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { err!("The client copy of this cipher is out of date. Resync the client and try again.") } Ok(_) => (), } } } if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id { err!("Organization mismatch. Please resync the client before updating the cipher") } if let Some(note) = &data.notes { let max_note_size = CONFIG._max_note_size(); if note.len() > max_note_size { err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters.")) } } // Check if this cipher is being transferred from a personal to an organization vault let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); if let Some(org_id) = data.organization_id { match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await { None => err!("You don't have permission to add item to organization"), Some(member) => { if shared_to_collections.is_some() || member.has_full_access() || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. // TODO: Audit/Check the whole save/update cipher chain. // Upstream uses the user_uuid to allow a cipher added by a user to an org to still allow the user to view/edit the cipher // even when the user has hide-passwords configured as there policy. // Removing the line below would fix that, but we have to check which effect this would have on the rest of the code. cipher.user_uuid = None; } else { err!("You don't have permission to add cipher directly to organization") } } } } else { cipher.user_uuid = Some(headers.user.uuid.clone()); } if let Some(ref folder_id) = data.folder_id { if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none() { err!("Invalid folder", "Folder does not exist or belongs to another user"); } } // Modify attachments name and keys when rotating if let Some(attachments) = data.attachments2 { for (id, attachment) in attachments { let mut saved_att = match Attachment::find_by_id(&id, conn).await { Some(att) => att, None => { // Warn and continue here. // A missing attachment means it was removed via an other client. // Also the Desktop Client supports removing attachments and save an update afterwards. // Bitwarden it self ignores these mismatches server side. warn!("Attachment {id} doesn't exist"); continue; } }; if saved_att.cipher_uuid != cipher.uuid { // 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); saved_att.file_name = attachment.file_name; saved_att.save(conn).await?; } } // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. // Also, upstream only retrieves keys they actually want to store, and thus skip the 'Response' key. // We do not mind which data is in it, the keep our model more flexible when there are upstream changes. // But, we at least know we do not need to store and return this specific key. fn _clean_cipher_data(mut json_data: Value) -> Value { if json_data.is_array() { json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| { f.as_object_mut().unwrap().remove("response"); }); }; json_data } let type_data_opt = match data.r#type { 1 => data.login, 2 => data.secure_note, 3 => data.card, 4 => data.identity, 5 => data.ssh_key, _ => err!("Invalid type"), }; let type_data = match type_data_opt { Some(mut data) => { // Remove the 'Response' key from the base object. data.as_object_mut().unwrap().remove("response"); // Remove the 'Response' key from every Uri. if data["uris"].is_array() { data["uris"] = _clean_cipher_data(data["uris"].clone()); } data } None => err!("Data missing"), }; cipher.key = data.key; cipher.name = data.name; cipher.notes = data.notes; cipher.fields = data.fields.map(|f| _clean_cipher_data(f).to_string()); cipher.data = type_data.to_string(); cipher.password_history = data.password_history.map(|f| f.to_string()); cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32); cipher.save(conn).await?; cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; if ut != UpdateType::None { // Only log events for organizational ciphers if let Some(org_id) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, (_, _) => EventType::CipherUpdated, }; log_event( event_type as i32, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } nt.send_cipher_update( ut, cipher, &cipher.update_users_revision(conn).await, &headers.device, shared_to_collections, conn, ) .await; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, folders: Vec, folder_relationships: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RelationsData { // Cipher id key: usize, // Folder id value: usize, } #[post("/ciphers/import", data = "")] async fn post_ciphers_import(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { enforce_personal_ownership_policy(None, &headers, &conn).await?; let data: ImportData = data.into_inner(); // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; // Read and create the folders let existing_folders: HashSet> = Folder::find_by_user(&headers.user.uuid, &conn).await.into_iter().map(|f| Some(f.uuid)).collect(); let mut folders: Vec = Vec::with_capacity(data.folders.len()); for folder in data.folders.into_iter() { let folder_id = if existing_folders.contains(&folder.id) { folder.id.unwrap() } else { let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name); new_folder.save(&conn).await?; new_folder.uuid }; folders.push(folder_id); } // Read the relations between folders and ciphers // Ciphers can only be in one folder at the same time let mut relations_map = HashMap::with_capacity(data.folder_relationships.len()); for relation in data.folder_relationships { relations_map.insert(relation.key, relation.value); } // Read and create the ciphers for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { let folder_id = relations_map.get(&index).map(|i| folders[*i].clone()); cipher_data.folder_id = folder_id; let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; } let mut user = headers.user; user.update_revision(&conn).await?; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; Ok(()) } /// Called when an org admin modifies an existing org cipher. #[put("/ciphers//admin", data = "")] async fn put_cipher_admin( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { put_cipher(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//admin", data = "")] async fn post_cipher_admin( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { post_cipher(cipher_id, data, headers, conn, nt).await } #[post("/ciphers/", data = "")] async fn post_cipher( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { put_cipher(cipher_id, data, headers, conn, nt).await } #[put("/ciphers/", data = "")] async fn put_cipher( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); let Some(mut cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; // TODO: Check if only the folder ID or favorite status is being changed. // These are per-user properties that technically aren't part of the // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[post("/ciphers//partial", data = "")] async fn post_cipher_partial( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { put_cipher_partial(cipher_id, data, headers, conn).await } // Only update the folder and favorite for the user, since this cipher is read-only #[put("/ciphers//partial", data = "")] async fn put_cipher_partial( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { let data: PartialCipherData = data.into_inner(); let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher does not exist") }; if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher does not exist", "Cipher is not accessible for the current user") } if let Some(ref folder_id) = data.folder_id { if Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, &conn).await.is_none() { err!("Invalid folder", "Folder does not exist or belongs to another user"); } } // Move cipher cipher.move_to_folder(data.folder_id.clone(), &headers.user.uuid, &conn).await?; // Update favorite cipher.set_favorite(Some(data.favorite), &headers.user.uuid, &conn).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionsAdminData { #[serde(alias = "CollectionIds")] collection_ids: Vec, } #[put("/ciphers//collections_v2", data = "")] async fn put_collections2_update( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { post_collections2_update(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//collections_v2", data = "")] async fn post_collections2_update( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let cipher_details = post_collections_update(cipher_id, data, headers, conn, nt).await?; Ok(Json(json!({ // AttachmentUploadDataResponseModel "object": "optionalCipherDetails", "unavailable": false, "cipher": *cipher_details }))) } #[put("/ciphers//collections", data = "")] async fn put_collections_update( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { post_collections_update(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//collections", data = "")] async fn post_collections_update( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: CollectionsAdminData = data.into_inner(); let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { err!("Collection cannot be changed") } let posted_collections = HashSet::::from_iter(data.collection_ids); let current_collections = HashSet::::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); for collection in posted_collections.symmetric_difference(¤t_collections) { match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if posted_collections.contains(&collection.uuid) { // Add to collection CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; } else { // Remove from collection CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; } } else { err!("No rights to modify the collection") } } } } nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(&conn).await, &headers.device, Some(Vec::from_iter(posted_collections)), &conn, ) .await; log_event( EventType::CipherUpdatedCollections as i32, &cipher.uuid, &cipher.organization_uuid.clone().unwrap(), &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[put("/ciphers//collections-admin", data = "")] async fn put_collections_admin( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { post_collections_admin(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//collections-admin", data = "")] async fn post_collections_admin( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data: CollectionsAdminData = data.into_inner(); let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { err!("Collection cannot be changed") } let posted_collections = HashSet::::from_iter(data.collection_ids); let current_collections = HashSet::::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); for collection in posted_collections.symmetric_difference(¤t_collections) { match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if posted_collections.contains(&collection.uuid) { // Add to collection CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await?; } else { // Remove from collection CollectionCipher::delete(&cipher.uuid, &collection.uuid, &conn).await?; } } else { err!("No rights to modify the collection") } } } } nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(&conn).await, &headers.device, Some(Vec::from_iter(posted_collections)), &conn, ) .await; log_event( EventType::CipherUpdatedCollections as i32, &cipher.uuid, &cipher.organization_uuid.unwrap(), &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ShareCipherData { #[serde(alias = "Cipher")] cipher: CipherData, #[serde(alias = "CollectionIds")] collection_ids: Vec, } #[post("/ciphers//share", data = "")] async fn post_cipher_share( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: ShareCipherData = data.into_inner(); share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } #[put("/ciphers//share", data = "")] async fn put_cipher_share( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: ShareCipherData = data.into_inner(); share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ShareSelectedCipherData { ciphers: Vec, collection_ids: Vec, } #[put("/ciphers/share", data = "")] async fn put_cipher_share_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let mut data: ShareSelectedCipherData = data.into_inner(); if data.ciphers.is_empty() { err!("You must select at least one cipher.") } if data.collection_ids.is_empty() { err!("You must select at least one collection.") } for cipher in data.ciphers.iter() { if cipher.id.is_none() { err!("Request missing ids field") } } while let Some(cipher) = data.ciphers.pop() { let mut shared_cipher_data = ShareCipherData { cipher, collection_ids: data.collection_ids.clone(), }; match shared_cipher_data.cipher.id.take() { Some(id) => { share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? } None => err!("Request missing ids field"), }; } // Multi share actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; Ok(()) } async fn share_cipher_by_uuid( cipher_id: &CipherId, data: ShareCipherData, headers: &Headers, conn: &DbConn, nt: &Notify<'_>, override_ut: Option, ) -> JsonResult { let mut cipher = match Cipher::find_by_uuid(cipher_id, conn).await { Some(cipher) => { if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher } else { err!("Cipher is not write accessible") } } None => err!("Cipher doesn't exist"), }; let mut shared_to_collections = vec![]; if let Some(organization_id) = &data.cipher.organization_id { for col_id in &data.collection_ids { match Collection::find_by_uuid_and_org(col_id, organization_id, conn).await { None => err!("Invalid collection ID provided"), Some(collection) => { if collection.is_writable_by_user(&headers.user.uuid, conn).await { CollectionCipher::save(&cipher.uuid, &collection.uuid, conn).await?; shared_to_collections.push(collection.uuid); } else { err!("No rights to modify the collection") } } } } }; // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. // If there is an override, like when handling multiple items, we want to prevent a push notification for every single item let ut = if let Some(ut) = override_ut { ut } else if data.cipher.last_known_revision_date.is_some() { UpdateType::SyncCipherUpdate } else { UpdateType::SyncCipherCreate }; update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } /// v2 API for downloading an attachment. This just redirects the client to /// the actual location of an attachment. /// /// Upstream added this v2 API to support direct download of attachments from /// their object storage service. For self-hosted instances, it basically just /// redirects to the same location as before the v2 API. #[get("/ciphers//attachment/")] async fn get_attachment( cipher_id: CipherId, attachment_id: AttachmentId, headers: Headers, conn: DbConn, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not accessible") } match Attachment::find_by_id(&attachment_id, &conn).await { Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AttachmentRequestData { key: String, file_name: String, file_size: NumberOrString, admin_request: Option, // true when attaching from an org vault view } enum FileUploadType { Direct = 0, // Azure = 1, // only used upstream } /// v2 API for creating an attachment associated with a cipher. /// This redirects the client to the API it should use to upload the attachment. /// For upstream's cloud-hosted service, it's an Azure object storage API. /// For self-hosted instances, it's another API on the local instance. #[post("/ciphers//attachment/v2", data = "")] async fn post_attachment_v2( cipher_id: CipherId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { err!("Attachment size can't be negative") } let attachment_id = crypto::generate_attachment_id(); let attachment = Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); attachment.save(&conn).await.expect("Error saving attachment"); let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); let response_key = match data.admin_request { Some(b) if b => "cipherMiniResponse", _ => "cipherResponse", }; Ok(Json(json!({ // AttachmentUploadDataResponseModel "object": "attachment-fileUpload", "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, }))) } #[derive(FromForm)] struct UploadData<'f> { key: Option, data: TempFile<'f>, } /// Saves the data content of an attachment to a file. This is common code /// shared between the v2 and legacy attachment APIs. /// /// When used with the legacy API, this function is responsible for creating /// the attachment database record, so `attachment` is None. /// /// When used with the v2 API, post_attachment_v2() has already created the /// database record, which is passed in as `attachment`. async fn save_attachment( mut attachment: Option, cipher_id: CipherId, data: Form>, headers: &Headers, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { let data = data.into_inner(); let Some(size) = data.data.len().to_i64() else { err!("Attachment data size overflow"); }; if size < 0 { err!("Attachment size can't be negative") } let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { err!("Cipher is not write accessible") } // In the v2 API, the attachment record has already been created, // so the size limit needs to be adjusted to account for that. let size_adjust = match &attachment { None => 0, // Legacy API Some(a) => a.file_size, // v2 API }; let size_limit = if let Some(ref user_id) = cipher.user_uuid { match CONFIG.user_attachment_limit() { Some(0) => err!("Attachments are disabled"), Some(limit_kb) => { let already_used = Attachment::size_by_user(user_id, &conn).await; let left = limit_kb .checked_mul(1024) .and_then(|l| l.checked_sub(already_used)) .and_then(|l| l.checked_add(size_adjust)); let Some(left) = left else { err!("Attachment size overflow"); }; if left <= 0 { err!("Attachment storage limit reached! Delete some attachments to free up space") } Some(left) } None => None, } } else if let Some(ref org_id) = cipher.organization_uuid { match CONFIG.org_attachment_limit() { Some(0) => err!("Attachments are disabled"), Some(limit_kb) => { let already_used = Attachment::size_by_org(org_id, &conn).await; let left = limit_kb .checked_mul(1024) .and_then(|l| l.checked_sub(already_used)) .and_then(|l| l.checked_add(size_adjust)); let Some(left) = left else { err!("Attachment size overflow"); }; if left <= 0 { err!("Attachment storage limit reached! Delete some attachments to free up space") } Some(left) } None => None, } } else { err!("Cipher is neither owned by a user nor an organization"); }; if let Some(size_limit) = size_limit { if size > size_limit { err!("Attachment storage limit exceeded with this file"); } } let file_id = match &attachment { Some(attachment) => attachment.id.clone(), // v2 API None => crypto::generate_attachment_id(), // Legacy API }; if let Some(attachment) = &mut attachment { // v2 API // Check the actual size against the size initially provided by // the client. Upstream allows +/- 1 MiB deviation from this // size, but it's not clear when or why this is needed. const LEEWAY: i64 = 1024 * 1024; // 1 MiB let Some(max_size) = attachment.file_size.checked_add(LEEWAY) else { err!("Invalid attachment size max") }; let Some(min_size) = attachment.file_size.checked_sub(LEEWAY) else { err!("Invalid attachment size min") }; if min_size <= size && size <= max_size { if size != attachment.file_size { // Update the attachment with the actual file size. attachment.file_size = size; attachment.save(&conn).await.expect("Error updating attachment"); } } else { attachment.delete(&conn).await.ok(); err!(format!("Attachment size mismatch (expected within [{min_size}, {max_size}], got {size})")); } } else { // Legacy API // SAFETY: This value is only stored in the database and is not used to access the file system. // As a result, the conditions specified by Rocket [0] are met and this is safe to use. // [0]: https://docs.rs/rocket/latest/rocket/fs/struct.FileName.html#-danger- let encrypted_filename = data.data.raw_name().map(|s| s.dangerous_unsafe_unsanitized_raw().to_string()); if encrypted_filename.is_none() { err!("No filename provided") } if data.key.is_none() { err!("No attachment key provided") } let attachment = Attachment::new(file_id.clone(), cipher_id.clone(), encrypted_filename.unwrap(), size, data.key); attachment.save(&conn).await.expect("Error saving attachment"); } save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(&conn).await, &headers.device, None, &conn, ) .await; if let Some(org_id) = &cipher.organization_uuid { log_event( EventType::CipherAttachmentCreated as i32, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; } Ok((cipher, conn)) } /// v2 API for uploading the actual data content of an attachment. /// This route needs a rank specified so that Rocket prioritizes the /// /ciphers//attachment/v2 route, which would otherwise conflict /// with this one. #[post("/ciphers//attachment/", format = "multipart/form-data", data = "", rank = 1)] async fn post_attachment_v2_data( cipher_id: CipherId, attachment_id: AttachmentId, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let attachment = match Attachment::find_by_id(&attachment_id, &conn).await { Some(attachment) if cipher_id == attachment.cipher_uuid => Some(attachment), Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), }; save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; Ok(()) } /// Legacy API for creating an attachment associated with a cipher. #[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( cipher_id: CipherId, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { // Setting this as None signifies to save_attachment() that it should create // the attachment database record as well as saving the data to disk. let attachment = None; let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] async fn post_attachment_admin( cipher_id: CipherId, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { post_attachment(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] async fn post_attachment_share( cipher_id: CipherId, attachment_id: AttachmentId, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; post_attachment(cipher_id, data, headers, conn, nt).await } #[post("/ciphers//attachment//delete-admin")] async fn delete_attachment_post_admin( cipher_id: CipherId, attachment_id: AttachmentId, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { delete_attachment(cipher_id, attachment_id, headers, conn, nt).await } #[post("/ciphers//attachment//delete")] async fn delete_attachment_post( cipher_id: CipherId, attachment_id: AttachmentId, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { delete_attachment(cipher_id, attachment_id, headers, conn, nt).await } #[delete("/ciphers//attachment/")] async fn delete_attachment( cipher_id: CipherId, attachment_id: AttachmentId, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await } #[delete("/ciphers//attachment//admin")] async fn delete_attachment_admin( cipher_id: CipherId, attachment_id: AttachmentId, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { _delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await } #[post("/ciphers//delete")] async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[post("/ciphers//delete-admin")] async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[put("/ciphers//delete")] async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await // soft delete } #[put("/ciphers//delete-admin")] async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await // soft delete } #[delete("/ciphers/")] async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[delete("/ciphers//admin")] async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await // permanent delete } #[delete("/ciphers", data = "")] async fn delete_cipher_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } #[post("/ciphers/delete", data = "")] async fn delete_cipher_selected_post( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } #[put("/ciphers/delete", data = "")] async fn delete_cipher_selected_put( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } #[delete("/ciphers/admin", data = "")] async fn delete_cipher_selected_admin( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } #[post("/ciphers/delete-admin", data = "")] async fn delete_cipher_selected_post_admin( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } #[put("/ciphers/delete-admin", data = "")] async fn delete_cipher_selected_put_admin( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } #[put("/ciphers//restore")] async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers//restore-admin")] async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { _restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await } #[put("/ciphers/restore-admin", data = "")] async fn restore_cipher_selected_admin( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { _restore_multiple_ciphers(data, &headers, &conn, &nt).await } #[put("/ciphers/restore", data = "")] async fn restore_cipher_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { _restore_multiple_ciphers(data, &headers, &conn, &nt).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MoveCipherData { folder_id: Option, ids: Vec, } #[post("/ciphers/move", data = "")] async fn move_cipher_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); let user_id = &headers.user.uuid; if let Some(ref folder_id) = data.folder_id { if Folder::find_by_uuid_and_user(folder_id, user_id, &conn).await.is_none() { err!("Invalid folder", "Folder does not exist or belongs to another user"); } } let cipher_count = data.ids.len(); let mut single_cipher: Option = None; // TODO: Convert this to use a single query (or at least less) to update all items // Find all ciphers a user has access to, all others will be ignored let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await; let accessible_ciphers_count = accessible_ciphers.len(); for cipher in accessible_ciphers { cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?; if cipher_count == 1 { single_cipher = Some(cipher); } } if let Some(cipher) = single_cipher { nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, std::slice::from_ref(user_id), &headers.device, None, &conn, ) .await; } else { // Multi move actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; } if cipher_count != accessible_ciphers_count { err!(format!( "Not all ciphers are moved! {accessible_ciphers_count} of the selected {cipher_count} were moved." )) } Ok(()) } #[put("/ciphers/move", data = "")] async fn move_cipher_selected_put( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { move_cipher_selected(data, headers, conn, nt).await } #[derive(FromForm)] struct OrganizationIdData { #[field(name = "organizationId")] org_id: OrganizationId, } #[post("/ciphers/purge?", data = "")] async fn delete_all( organization: Option, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let mut user = headers.user; data.validate(&user, true, &conn).await?; match organization { Some(org_data) => { // Organization ID in query params, purging organization vault match Membership::find_by_user_and_org(&user.uuid, &org_data.org_id, &conn).await { None => err!("You don't have permission to purge the organization vault"), Some(member) => { if member.atype == MembershipType::Owner { Cipher::delete_all_by_organization(&org_data.org_id, &conn).await?; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; log_event( EventType::OrganizationPurgedVault as i32, &org_data.org_id, &org_data.org_id, &user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(()) } else { err!("You don't have permission to purge the organization vault"); } } } } None => { // No organization ID in query params, purging user vault // Delete ciphers and their attachments for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await { cipher.delete(&conn).await?; } // Delete folders for f in Folder::find_by_user(&user.uuid, &conn).await { f.delete(&conn).await?; } user.update_revision(&conn).await?; nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; Ok(()) } } } #[derive(PartialEq)] pub enum CipherDeleteOptions { SoftSingle, SoftMulti, HardSingle, HardMulti, } async fn _delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, ) -> EmptyResult { let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher can't be deleted by user") } if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti { cipher.deleted_at = Some(Utc::now().naive_utc()); cipher.save(conn).await?; if *delete_options == CipherDeleteOptions::SoftSingle { nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(conn).await, &headers.device, None, conn, ) .await; } } else { cipher.delete(conn).await?; if *delete_options == CipherDeleteOptions::HardSingle { nt.send_cipher_update( UpdateType::SyncLoginDelete, &cipher, &cipher.update_users_revision(conn).await, &headers.device, None, conn, ) .await; } } if let Some(org_id) = cipher.organization_uuid { let event_type = if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti { EventType::CipherSoftDeleted as i32 } else { EventType::CipherDeleted as i32 }; log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) .await; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CipherIdsData { ids: Vec, } async fn _delete_multiple_ciphers( data: Json, headers: Headers, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); for cipher_id in data.ids { if let error @ Err(_) = _delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { return error; }; } // Multi delete actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; Ok(()) } async fn _restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { let Some(mut cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher can't be restored by user") } cipher.deleted_at = None; cipher.save(conn).await?; if !multi_restore { nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(conn).await, &headers.device, None, conn, ) .await; } if let Some(org_id) = &cipher.organization_uuid { log_event( EventType::CipherRestored as i32, &cipher.uuid.clone(), org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) } async fn _restore_multiple_ciphers( data: Json, headers: &Headers, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { let data = data.into_inner(); let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { match _restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } } // Multi move actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await; Ok(Json(json!({ "data": ciphers, "object": "list", "continuationToken": null }))) } async fn _delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { let Some(attachment) = Attachment::find_by_id(attachment_id, conn).await else { err!("Attachment doesn't exist") }; if &attachment.cipher_uuid != cipher_id { err!("Attachment from other cipher") } let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else { err!("Cipher doesn't exist") }; if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { err!("Cipher cannot be deleted by user") } // Delete attachment attachment.delete(conn).await?; nt.send_cipher_update( UpdateType::SyncCipherUpdate, &cipher, &cipher.update_users_revision(conn).await, &headers.device, None, conn, ) .await; if let Some(ref org_id) = cipher.organization_uuid { log_event( EventType::CipherAttachmentDeleted as i32, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; Ok(Json(json!({"cipher":cipher_json}))) } /// This will hold all the necessary data to improve a full sync of all the ciphers /// It can be used during the `Cipher::to_json()` call. /// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed. /// This will not improve the speed of a single cipher.to_json() call that much, so better not to use it for those calls. pub struct CipherSyncData { pub cipher_attachments: HashMap>, pub cipher_folders: HashMap, pub cipher_favorites: HashSet, pub cipher_collections: HashMap>, pub members: HashMap, pub user_collections: HashMap, pub user_collections_groups: HashMap, pub user_group_full_access_for_organizations: HashSet, } #[derive(Eq, PartialEq)] pub enum CipherSyncType { User, Organization, } impl CipherSyncData { pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self { let cipher_folders: HashMap; let cipher_favorites: HashSet; match sync_type { // User Sync supports Folders and Favorites CipherSyncType::User => { // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); // Generate a HashSet of all the Cipher UUID's which are marked as favorite cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect(); } // Organization Sync does not support Folders and Favorites. // If these are set, it will cause issues in the web-vault. CipherSyncType::Organization => { cipher_folders = HashMap::with_capacity(0); cipher_favorites = HashSet::with_capacity(0); } } // Generate a list of Cipher UUID's containing a Vec with one or more Attachment records let orgs = Membership::get_orgs_by_user(user_id, conn).await; let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await; let mut cipher_attachments: HashMap> = HashMap::with_capacity(attachments.len()); for attachment in attachments { cipher_attachments.entry(attachment.cipher_uuid.clone()).or_default().push(attachment); } // Generate a HashMap with the Cipher UUID as key and one or more Collection UUID's let user_cipher_collections = Cipher::get_collections_with_cipher_by_user(user_id.clone(), conn).await; let mut cipher_collections: HashMap> = HashMap::with_capacity(user_cipher_collections.len()); for (cipher, collection) in user_cipher_collections { cipher_collections.entry(cipher).or_default().push(collection); } // Generate a HashMap with the Organization UUID as key and the Membership record let members: HashMap = Membership::find_by_user(user_id, conn).await.into_iter().map(|m| (m.org_uuid.clone(), m)).collect(); // Generate a HashMap with the User_Collections UUID as key and the CollectionUser record let user_collections: HashMap = CollectionUser::find_by_user(user_id, conn) .await .into_iter() .map(|uc| (uc.collection_uuid.clone(), uc)) .collect(); // Generate a HashMap with the collections_uuid as key and the CollectionGroup record let user_collections_groups: HashMap = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_user(user_id, conn).await.into_iter().fold( HashMap::new(), |mut combined_permissions, cg| { combined_permissions .entry(cg.collections_uuid.clone()) .and_modify(|existing| { // Combine permissions: take the most permissive settings. existing.read_only &= cg.read_only; // false if ANY group allows write existing.hide_passwords &= cg.hide_passwords; // false if ANY group allows password view existing.manage |= cg.manage; // true if ANY group allows manage }) .or_insert(cg); combined_permissions }, ) } else { HashMap::new() }; // Get all organizations that the given user has full access to via group assignment let user_group_full_access_for_organizations: HashSet = if CONFIG.org_groups_enabled() { Group::get_orgs_by_user_with_full_access(user_id, conn).await.into_iter().collect() } else { HashSet::new() }; Self { cipher_attachments, cipher_folders, cipher_favorites, cipher_collections, members, user_collections, user_collections_groups, user_group_full_access_for_organizations, } } } ================================================ FILE: src/api/core/emergency_access.rs ================================================ use chrono::{TimeDelta, Utc}; use rocket::{serde::json::Json, Route}; use serde_json::Value; use crate::{ api::{ core::{CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, }, auth::{decode_emergency_access_invite, Headers}, db::{ models::{ Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, }, DbConn, DbPool, }, mail, util::NumberOrString, CONFIG, }; pub fn routes() -> Vec { routes![ get_contacts, get_grantees, get_emergency_access, put_emergency_access, post_emergency_access, delete_emergency_access, post_delete_emergency_access, send_invite, resend_invite, accept_invite, confirm_emergency_access, initiate_emergency_access, approve_emergency_access, reject_emergency_access, takeover_emergency_access, password_emergency_access, view_emergency_access, policies_emergency_access, ] } // region get #[get("/emergency-access/trusted")] async fn get_contacts(headers: Headers, conn: DbConn) -> Json { let emergency_access_list = if CONFIG.emergency_access_allowed() { EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn).await } else { Vec::new() }; let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); for ea in emergency_access_list { if let Some(grantee) = ea.to_json_grantee_details(&conn).await { emergency_access_list_json.push(grantee) } } Json(json!({ "data": emergency_access_list_json, "object": "list", "continuationToken": null })) } #[get("/emergency-access/granted")] async fn get_grantees(headers: Headers, conn: DbConn) -> Json { let emergency_access_list = if CONFIG.emergency_access_allowed() { EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &conn).await } else { Vec::new() }; let mut emergency_access_list_json = Vec::with_capacity(emergency_access_list.len()); for ea in emergency_access_list { emergency_access_list_json.push(ea.to_json_grantor_details(&conn).await); } Json(json!({ "data": emergency_access_list_json, "object": "list", "continuationToken": null })) } #[get("/emergency-access/")] async fn get_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await { Some(emergency_access) => Ok(Json( emergency_access.to_json_grantee_details(&conn).await.expect("Grantee user should exist but does not!"), )), None => err!("Emergency access not valid."), } } // endregion // region put/post #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessUpdateData { r#type: NumberOrString, wait_time_days: i32, key_encrypted: Option, } #[put("/emergency-access/", data = "")] async fn put_emergency_access( emer_id: EmergencyAccessId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { post_emergency_access(emer_id, data, headers, conn).await } #[post("/emergency-access/", data = "")] async fn post_emergency_access( emer_id: EmergencyAccessId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { check_emergency_access_enabled()?; let data: EmergencyAccessUpdateData = data.into_inner(); let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { Some(new_type) => new_type as i32, None => err!("Invalid emergency access type."), }; emergency_access.atype = new_type; emergency_access.wait_time_days = data.wait_time_days; if data.key_encrypted.is_some() { emergency_access.key_encrypted = data.key_encrypted; } emergency_access.save(&conn).await?; Ok(Json(emergency_access.to_json())) } // endregion // region delete #[delete("/emergency-access/")] async fn delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let emergency_access = match ( EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await, EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await, ) { (Some(grantor_emer), None) => { info!("Grantor deleted emergency access {emer_id}"); grantor_emer } (None, Some(grantee_emer)) => { info!("Grantee deleted emergency access {emer_id}"); grantee_emer } _ => err!("Emergency access not valid."), }; emergency_access.delete(&conn).await?; Ok(()) } #[post("/emergency-access//delete")] async fn post_delete_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { delete_emergency_access(emer_id, headers, conn).await } // endregion // region invite #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessInviteData { email: String, r#type: NumberOrString, wait_time_days: i32, } #[post("/emergency-access/invite", data = "")] async fn send_invite(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let data: EmergencyAccessInviteData = data.into_inner(); let email = data.email.to_lowercase(); let wait_time_days = data.wait_time_days; let emergency_access_status = EmergencyAccessStatus::Invited as i32; let new_type = match EmergencyAccessType::from_str(&data.r#type.into_string()) { Some(new_type) => new_type as i32, None => err!("Invalid emergency access type."), }; let grantor_user = headers.user; // avoid setting yourself as emergency contact if email == grantor_user.email { err!("You can not set yourself as an emergency contact.") } let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { err!(format!("Grantee user does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(&email) { err!("Email domain not eligible for invitations") } if !CONFIG.mail_enabled() { let invitation = Invitation::new(&email); invitation.save(&conn).await?; } let mut user = User::new(&email, None); user.save(&conn).await?; (user, true) } Some(user) if user.password_hash.is_empty() => (user, true), Some(user) => (user, false), }; if EmergencyAccess::find_by_grantor_uuid_and_grantee_uuid_or_email( &grantor_user.uuid, &grantee_user.uuid, &grantee_user.email, &conn, ) .await .is_some() { err!(format!("Grantee user already invited: {}", &grantee_user.email)) } let mut new_emergency_access = EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); new_emergency_access.save(&conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite( &new_emergency_access.email.expect("Grantee email does not exists"), grantee_user.uuid, new_emergency_access.uuid, &grantor_user.name, &grantor_user.email, ) .await?; } else if !new_user { // if mail is not enabled immediately accept the invitation for existing users new_emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?; } Ok(()) } #[post("/emergency-access//reinvite")] async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> EmptyResult { check_emergency_access_enabled()?; let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::Invited as i32 { err!("The grantee user is already accepted or confirmed to the organization"); } let Some(email) = emergency_access.email.clone() else { err!("Email not valid.") }; let Some(grantee_user) = User::find_by_mail(&email, &conn).await else { err!("Grantee user not found.") }; let grantor_user = headers.user; if CONFIG.mail_enabled() { mail::send_emergency_access_invite( &email, grantor_user.uuid, emergency_access.uuid, &grantor_user.name, &grantor_user.email, ) .await?; } else if !grantee_user.password_hash.is_empty() { // accept the invitation for existing user emergency_access.accept_invite(&grantee_user.uuid, &email, &conn).await?; } else if CONFIG.invitations_allowed() && Invitation::find_by_mail(&email, &conn).await.is_none() { let invitation = Invitation::new(&email); invitation.save(&conn).await?; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AcceptData { token: String, } #[post("/emergency-access//accept", data = "")] async fn accept_invite( emer_id: EmergencyAccessId, data: Json, headers: Headers, conn: DbConn, ) -> EmptyResult { check_emergency_access_enabled()?; let data: AcceptData = data.into_inner(); let token = &data.token; let claims = decode_emergency_access_invite(token)?; // This can happen if the user who received the invite used a different email to signup. // Since we do not know if this is intended, we error out here and do nothing with the invite. if claims.email != headers.user.email { err!("Claim email does not match current users email") } let grantee_user = match User::find_by_mail(&claims.email, &conn).await { Some(user) => { Invitation::take(&claims.email, &conn).await; user } None => err!("Invited user not found"), }; // We need to search for the uuid in combination with the email, since we do not yet store the uuid of the grantee in the database. // The uuid of the grantee gets stored once accepted. let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_email(&emer_id, &headers.user.email, &conn).await else { err!("Emergency access not valid.") }; // get grantor user to send Accepted email let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { err!("Grantor user not found.") }; if emer_id == claims.emer_id && grantor_user.name == claims.grantor_name && grantor_user.email == claims.grantor_email { emergency_access.accept_invite(&grantee_user.uuid, &grantee_user.email, &conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email).await?; } Ok(()) } else { err!("Emergency access invitation error.") } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ConfirmData { key: String, } #[post("/emergency-access//confirm", data = "")] async fn confirm_emergency_access( emer_id: EmergencyAccessId, data: Json, headers: Headers, conn: DbConn, ) -> JsonResult { check_emergency_access_enabled()?; let confirming_user = headers.user; let data: ConfirmData = data.into_inner(); let key = data.key; let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &confirming_user.uuid, &conn).await else { err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::Accepted as i32 || emergency_access.grantor_uuid != confirming_user.uuid { err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&confirming_user.uuid, &conn).await else { err!("Grantor user not found.") }; if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { err!("Grantee user not found.") }; emergency_access.status = EmergencyAccessStatus::Confirmed as i32; emergency_access.key_encrypted = Some(key); emergency_access.email = None; emergency_access.save(&conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name).await?; } Ok(Json(emergency_access.to_json())) } else { err!("Grantee user not found.") } } // endregion // region access emergency access #[post("/emergency-access//initiate")] async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let initiating_user = headers.user; let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await else { err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { err!("Grantor user not found.") }; let now = Utc::now().naive_utc(); emergency_access.status = EmergencyAccessStatus::RecoveryInitiated as i32; emergency_access.updated_at = now; emergency_access.recovery_initiated_at = Some(now); emergency_access.last_notification_at = Some(now); emergency_access.save(&conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_initiated( &grantor_user.email, &initiating_user.name, emergency_access.get_type_as_str(), &emergency_access.wait_time_days, ) .await?; } Ok(Json(emergency_access.to_json())) } #[post("/emergency-access//approve")] async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { err!("Grantor user not found.") }; if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { err!("Grantee user not found.") }; emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32; emergency_access.save(&conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await?; } Ok(Json(emergency_access.to_json())) } else { err!("Grantee user not found.") } } #[post("/emergency-access//reject")] async fn reject_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 && emergency_access.status != EmergencyAccessStatus::RecoveryApproved as i32 { err!("Emergency access not valid.") } if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { err!("Grantee user not found.") }; emergency_access.status = EmergencyAccessStatus::Confirmed as i32; emergency_access.save(&conn).await?; if CONFIG.mail_enabled() { mail::send_emergency_access_recovery_rejected(&grantee_user.email, &headers.user.name).await?; } Ok(Json(emergency_access.to_json())) } else { err!("Grantee user not found.") } } // endregion // region action #[post("/emergency-access//view")] async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let Some(emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) { err!("Emergency access not valid.") } let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &conn).await; let cipher_sync_data = CipherSyncData::new(&emergency_access.grantor_uuid, CipherSyncType::User, &conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push( c.to_json( &headers.host, &emergency_access.grantor_uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn, ) .await?, ); } Ok(Json(json!({ "ciphers": ciphers_json, "keyEncrypted": &emergency_access.key_encrypted, "object": "emergencyAccessView", }))) } #[post("/emergency-access//takeover")] async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; let requesting_user = headers.user; let Some(emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await else { err!("Emergency access not valid.") }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { err!("Grantor user not found.") }; let result = json!({ "kdf": grantor_user.client_kdf_type, "kdfIterations": grantor_user.client_kdf_iter, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, "object": "emergencyAccessTakeover", }); Ok(Json(result)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { new_master_password_hash: String, key: String, } #[post("/emergency-access//password", data = "")] async fn password_emergency_access( emer_id: EmergencyAccessId, data: Json, headers: Headers, conn: DbConn, ) -> EmptyResult { check_emergency_access_enabled()?; let data: EmergencyAccessPasswordData = data.into_inner(); let new_master_password_hash = &data.new_master_password_hash; //let key = &data.Key; let requesting_user = headers.user; let Some(emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await else { err!("Emergency access not valid.") }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } let Some(mut grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { err!("Grantor user not found.") }; // change grantor_user password grantor_user.set_password(new_master_password_hash, Some(data.key), true, None); grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn).await?; // Remove grantor from all organisations unless Owner for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await { if member.atype != MembershipType::Owner as i32 { member.delete(&conn).await?; } } Ok(()) } // endregion #[get("/emergency-access//policies")] async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { let requesting_user = headers.user; let Some(emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &requesting_user.uuid, &conn).await else { err!("Emergency access not valid.") }; if !is_valid_request(&emergency_access, &requesting_user.uuid, EmergencyAccessType::Takeover) { err!("Emergency access not valid.") } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { err!("Grantor user not found.") }; let policies = OrgPolicy::find_confirmed_by_user(&grantor_user.uuid, &conn); let policies_json: Vec = policies.await.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ "data": policies_json, "object": "list", "continuationToken": null }))) } fn is_valid_request( emergency_access: &EmergencyAccess, requesting_user_id: &UserId, requested_access_type: EmergencyAccessType, ) -> bool { emergency_access.grantee_uuid.is_some() && emergency_access.grantee_uuid.as_ref().unwrap() == requesting_user_id && emergency_access.status == EmergencyAccessStatus::RecoveryApproved as i32 && emergency_access.atype == requested_access_type as i32 } fn check_emergency_access_enabled() -> EmptyResult { if !CONFIG.emergency_access_allowed() { err!("Emergency access is not enabled.") } Ok(()) } pub async fn emergency_request_timeout_job(pool: DbPool) { debug!("Start emergency_request_timeout_job"); if !CONFIG.emergency_access_allowed() { return; } if let Ok(conn) = pool.get().await { let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; if emergency_access_list.is_empty() { debug!("No emergency request timeout to approve"); } let now = Utc::now().naive_utc(); for mut emer in emergency_access_list { // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) let recovery_allowed_at = emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap(); if recovery_allowed_at.le(&now) { // Only update the access status // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn) .await .expect("Unable to update emergency access status"); if CONFIG.mail_enabled() { // get grantor user to send Accepted email let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); // get grantee user to send Accepted email let grantee_user = User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) .await .expect("Grantee user not found"); mail::send_emergency_access_recovery_timed_out( &grantor_user.email, &grantee_user.name, emer.get_type_as_str(), ) .await .expect("Error on sending email"); mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) .await .expect("Error on sending email"); } } } } else { error!("Failed to get DB connection while searching emergency request timed out") } } pub async fn emergency_notification_reminder_job(pool: DbPool) { debug!("Start emergency_notification_reminder_job"); if !CONFIG.emergency_access_allowed() { return; } if let Ok(conn) = pool.get().await { let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; if emergency_access_list.is_empty() { debug!("No emergency request reminder notification to send"); } let now = Utc::now().naive_utc(); for mut emer in emergency_access_list { // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) // Calculate the day before the recovery will become active let final_recovery_reminder_at = emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); // Calculate if a day has passed since the previous notification, else no notification has been sent before let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { last_notification_at + TimeDelta::try_days(1).unwrap() } else { now }; if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) { // Only update the last notification date // Updating the whole record could cause issues when the emergency_request_timeout_job is also active emer.update_last_notification_date_and_save(&now, &conn) .await .expect("Unable to update emergency access notification date"); if CONFIG.mail_enabled() { // get grantor user to send Accepted email let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); // get grantee user to send Accepted email let grantee_user = User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) .await .expect("Grantee user not found"); mail::send_emergency_access_recovery_reminder( &grantor_user.email, &grantee_user.name, emer.get_type_as_str(), "1", // This notification is only triggered one day before the activation ) .await .expect("Error on sending email"); } } } } else { error!("Failed to get DB connection while searching emergency notification reminder") } } ================================================ FILE: src/api/core/events.rs ================================================ use std::net::IpAddr; use chrono::NaiveDateTime; use rocket::{form::FromForm, serde::json::Json, Route}; use serde_json::Value; use crate::{ api::{EmptyResult, JsonResult}, auth::{AdminHeaders, Headers}, db::{ models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, DbConn, DbPool, }, util::parse_date, CONFIG, }; /// ############################################################################################################### /// /api routes pub fn routes() -> Vec { routes![get_org_events, get_cipher_events, get_user_events,] } #[derive(FromForm)] struct EventRange { start: String, end: String, #[field(name = "continuationToken")] continuation_token: Option, } // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 #[get("/organizations//events?")] async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { Vec::with_capacity(0) } else { let start_date = parse_date(&data.start); let end_date = if let Some(before_date) = &data.continuation_token { parse_date(before_date) } else { parse_date(&data.end) }; Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) .await .iter() .map(|e| e.to_json()) .collect() }; Ok(Json(json!({ "data": events_json, "object": "list", "continuationToken": get_continuation_token(&events_json), }))) } #[get("/ciphers//events?")] async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { Vec::with_capacity(0) } else { let mut events_json = Vec::with_capacity(0); if Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await { let start_date = parse_date(&data.start); let end_date = if let Some(before_date) = &data.continuation_token { parse_date(before_date) } else { parse_date(&data.end) }; events_json = Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn) .await .iter() .map(|e| e.to_json()) .collect() } events_json }; Ok(Json(json!({ "data": events_json, "object": "list", "continuationToken": get_continuation_token(&events_json), }))) } #[get("/organizations//users//events?")] async fn get_user_events( org_id: OrganizationId, member_id: MembershipId, data: EventRange, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } // Return an empty vec when we org events are disabled. // This prevents client errors let events_json: Vec = if !CONFIG.org_events_enabled() { Vec::with_capacity(0) } else { let start_date = parse_date(&data.start); let end_date = if let Some(before_date) = &data.continuation_token { parse_date(before_date) } else { parse_date(&data.end) }; Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) .await .iter() .map(|e| e.to_json()) .collect() }; Ok(Json(json!({ "data": events_json, "object": "list", "continuationToken": get_continuation_token(&events_json), }))) } fn get_continuation_token(events_json: &[Value]) -> Option<&str> { // When the length of the vec equals the max page_size there probably is more data // When it is less, then all events are loaded. if events_json.len() as i64 == Event::PAGE_SIZE { if let Some(last_event) = events_json.last() { last_event["date"].as_str() } else { None } } else { None } } /// ############################################################################################################### /// /events routes pub fn main_routes() -> Vec { routes![post_events_collect,] } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EventCollection { // Mandatory r#type: i32, date: String, // Optional cipher_id: Option, organization_id: Option, } // Upstream: // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs #[post("/collect", format = "application/json", data = "")] async fn post_events_collect(data: Json>, headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.org_events_enabled() { return Ok(()); } for event in data.iter() { let event_date = parse_date(&event.date); match event.r#type { 1000..=1099 => { _log_user_event( event.r#type, &headers.user.uuid, headers.device.atype, Some(event_date), &headers.ip.ip, &conn, ) .await; } 1600..=1699 => { if let Some(org_id) = &event.organization_id { _log_event( event.r#type, org_id, org_id, &headers.user.uuid, headers.device.atype, Some(event_date), &headers.ip.ip, &conn, ) .await; } } _ => { if let Some(cipher_uuid) = &event.cipher_id { if let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await { if let Some(org_id) = cipher.organization_uuid { _log_event( event.r#type, cipher_uuid, &org_id, &headers.user.uuid, headers.device.atype, Some(event_date), &headers.ip.ip, &conn, ) .await; } } } } } } Ok(()) } pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } _log_user_event(event_type, user_id, device_type, None, ip, conn).await; } async fn _log_user_event( event_type: i32, user_id: &UserId, device_type: i32, event_date: Option, ip: &IpAddr, conn: &DbConn, ) { let memberships = Membership::find_by_user(user_id, conn).await; let mut events: Vec = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org // Upstream saves the event also without any org_id. let mut event = Event::new(event_type, event_date); event.user_uuid = Some(user_id.clone()); event.act_user_uuid = Some(user_id.clone()); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); events.push(event); // For each org a user is a member of store these events per org for membership in memberships { let mut event = Event::new(event_type, event_date); event.user_uuid = Some(user_id.clone()); event.org_uuid = Some(membership.org_uuid); event.org_user_uuid = Some(membership.uuid); event.act_user_uuid = Some(user_id.clone()); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); events.push(event); } Event::save_user_event(events, conn).await.unwrap_or(()); } pub async fn log_event( event_type: i32, source_uuid: &str, org_id: &OrganizationId, act_user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn, ) { if !CONFIG.org_events_enabled() { return; } _log_event(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; } #[allow(clippy::too_many_arguments)] async fn _log_event( event_type: i32, source_uuid: &str, org_id: &OrganizationId, act_user_id: &UserId, device_type: i32, event_date: Option, ip: &IpAddr, conn: &DbConn, ) { // Create a new empty event let mut event = Event::new(event_type, event_date); match event_type { // 1000..=1099 Are user events, they need to be logged via log_user_event() // Cipher Events 1100..=1199 => { event.cipher_uuid = Some(source_uuid.to_string().into()); } // Collection Events 1300..=1399 => { event.collection_uuid = Some(source_uuid.to_string().into()); } // Group Events 1400..=1499 => { event.group_uuid = Some(source_uuid.to_string().into()); } // Org User Events 1500..=1599 => { event.org_user_uuid = Some(source_uuid.to_string().into()); } // 1600..=1699 Are organizational events, and they do not need the source_uuid // Policy Events 1700..=1799 => { event.policy_uuid = Some(source_uuid.to_string().into()); } // Ignore others _ => {} } event.org_uuid = Some(org_id.clone()); event.act_user_uuid = Some(act_user_id.clone()); event.device_type = Some(device_type); event.ip_address = Some(ip.to_string()); event.save(conn).await.unwrap_or(()); } pub async fn event_cleanup_job(pool: DbPool) { debug!("Start events cleanup job"); if CONFIG.events_days_retain().is_none() { debug!("events_days_retain is not configured, abort"); return; } if let Ok(conn) = pool.get().await { Event::clean_events(&conn).await.ok(); } else { error!("Failed to get DB connection while trying to cleanup the events table") } } ================================================ FILE: src/api/core/folders.rs ================================================ use rocket::serde::json::Json; use serde_json::Value; use crate::{ api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, db::{ models::{Folder, FolderId}, DbConn, }, }; pub fn routes() -> Vec { routes![get_folders, get_folder, post_folders, post_folder, put_folder, delete_folder_post, delete_folder,] } #[get("/folders")] async fn get_folders(headers: Headers, conn: DbConn) -> Json { let folders = Folder::find_by_user(&headers.user.uuid, &conn).await; let folders_json: Vec = folders.iter().map(Folder::to_json).collect(); Json(json!({ "data": folders_json, "object": "list", "continuationToken": null, })) } #[get("/folders/")] async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> JsonResult { match Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await { Some(folder) => Ok(Json(folder.to_json())), _ => err!("Invalid folder", "Folder does not exist or belongs to another user"), } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct FolderData { pub name: String, pub id: Option, } #[post("/folders", data = "")] async fn post_folders(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: FolderData = data.into_inner(); let mut folder = Folder::new(headers.user.uuid, data.name); folder.save(&conn).await?; nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device, &conn).await; Ok(Json(folder.to_json())) } #[post("/folders/", data = "")] async fn post_folder( folder_id: FolderId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { put_folder(folder_id, data, headers, conn, nt).await } #[put("/folders/", data = "")] async fn put_folder( folder_id: FolderId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let data: FolderData = data.into_inner(); let Some(mut folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else { err!("Invalid folder", "Folder does not exist or belongs to another user") }; folder.name = data.name; folder.save(&conn).await?; nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device, &conn).await; Ok(Json(folder.to_json())) } #[post("/folders//delete")] async fn delete_folder_post(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { delete_folder(folder_id, headers, conn, nt).await } #[delete("/folders/")] async fn delete_folder(folder_id: FolderId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let Some(folder) = Folder::find_by_uuid_and_user(&folder_id, &headers.user.uuid, &conn).await else { err!("Invalid folder", "Folder does not exist or belongs to another user") }; // Delete the actual folder entry folder.delete(&conn).await?; nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device, &conn).await; Ok(()) } ================================================ FILE: src/api/core/mod.rs ================================================ pub mod accounts; mod ciphers; mod emergency_access; mod events; mod folders; mod organizations; mod public; mod sends; pub mod two_factor; pub use accounts::purge_auth_requests; pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; use reqwest::Method; pub use sends::purge_sends; pub fn routes() -> Vec { let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; let mut hibp_routes = routes![hibp_breach]; let mut meta_routes = routes![alive, now, version, config, get_api_webauthn]; let mut routes = Vec::new(); routes.append(&mut accounts::routes()); routes.append(&mut ciphers::routes()); routes.append(&mut emergency_access::routes()); routes.append(&mut events::routes()); routes.append(&mut folders::routes()); routes.append(&mut organizations::routes()); routes.append(&mut two_factor::routes()); routes.append(&mut sends::routes()); routes.append(&mut public::routes()); routes.append(&mut eq_domains_routes); routes.append(&mut hibp_routes); routes.append(&mut meta_routes); routes } pub fn events_routes() -> Vec { let mut routes = Vec::new(); routes.append(&mut events::main_routes()); routes } // // Move this somewhere else // use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use crate::{ api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, db::{ models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, DbConn, }, error::Error, http_client::make_http_request, mail, util::parse_experimental_client_feature_flags, }; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct GlobalDomain { r#type: i32, domains: Vec, excluded: bool, } const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json"); #[get("/settings/domains")] fn get_eq_domains(headers: Headers) -> Json { _get_eq_domains(&headers, false) } fn _get_eq_domains(headers: &Headers, no_excluded: bool) -> Json { let user = &headers.user; use serde_json::from_str; let equivalent_domains: Vec> = from_str(&user.equivalent_domains).unwrap(); let excluded_globals: Vec = from_str(&user.excluded_globals).unwrap(); let mut globals: Vec = from_str(GLOBAL_DOMAINS).unwrap(); for global in &mut globals { global.excluded = excluded_globals.contains(&global.r#type); } if no_excluded { globals.retain(|g| !g.excluded); } Json(json!({ "equivalentDomains": equivalent_domains, "globalEquivalentDomains": globals, "object": "domains", })) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EquivDomainData { excluded_global_equivalent_domains: Option>, equivalent_domains: Option>>, } #[post("/settings/domains", data = "")] async fn post_eq_domains(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { let data: EquivDomainData = data.into_inner(); let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default(); let equivalent_domains = data.equivalent_domains.unwrap_or_default(); let mut user = headers.user; use serde_json::to_string; user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string()); user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string()); user.save(&conn).await?; nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await; Ok(Json(json!({}))) } #[put("/settings/domains", data = "")] async fn put_eq_domains(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { post_eq_domains(data, headers, conn, nt).await } #[get("/hibp/breach?")] async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult { let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect(); if let Some(api_key) = crate::CONFIG.hibp_api_key() { let url = format!( "https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false" ); let res = make_http_request(Method::GET, &url)?.header("hibp-api-key", api_key).send().await?; // If we get a 404, return a 404, it means no breached accounts if res.status() == 404 { return Err(Error::empty().with_code(404)); } let value: Value = res.error_for_status()?.json().await?; Ok(Json(value)) } else { Ok(Json(json!([{ "name": "HaveIBeenPwned", "title": "Manual HIBP Check", "domain": "haveibeenpwned.com", "breachDate": "2019-08-18T00:00:00Z", "addedDate": "2019-08-18T00:00:00Z", "description": format!("Go to: https://haveibeenpwned.com/account/{username} for a manual check.

HaveIBeenPwned API key not set!
Go to https://haveibeenpwned.com/API/Key to purchase an API key from HaveIBeenPwned.

"), "logoPath": "vw_static/hibp.png", "pwnCount": 0, "dataClasses": [ "Error - No API key set!" ] }]))) } } // We use DbConn here to let the alive healthcheck also verify the database connection. #[get("/alive")] fn alive(_conn: DbConn) -> Json { now() } #[get("/now")] pub fn now() -> Json { Json(crate::util::format_date(&chrono::Utc::now().naive_utc())) } #[get("/version")] fn version() -> Json<&'static str> { Json(crate::VERSION.unwrap_or_default()) } #[get("/webauthn")] fn get_api_webauthn(_headers: Headers) -> Json { // Prevent a 404 error, which also causes key-rotation issues // It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support // An empty list/data also works fine Json(json!({ "object": "list", "data": [], "continuationToken": null })) } #[get("/config")] fn config() -> Json { let domain = crate::CONFIG.domain(); // Official available feature flags can be found here: // Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103 // Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12 // Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22 // iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 let mut feature_states = parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags()); feature_states.insert("duo-redirect".to_string(), true); feature_states.insert("email-verification".to_string(), true); feature_states.insert("unauth-ui-refresh".to_string(), true); feature_states.insert("enable-pm-flight-recorder".to_string(), true); feature_states.insert("mobile-error-reporting".to_string(), true); Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns // This means they expect a version that closely matches the Bitwarden server version // We should make sure that we keep this updated when we support the new server features // Version history: // - Individual cipher key encryption: 2024.2.0 // - Mobile app support for MasterPasswordUnlockData: 2025.8.0 "version": "2025.12.0", "gitHash": option_env!("GIT_REV"), "server": { "name": "Vaultwarden", "url": "https://github.com/dani-garcia/vaultwarden" }, "settings": { "disableUserRegistration": crate::CONFIG.is_signup_disabled() }, "environment": { "vault": domain, "api": format!("{domain}/api"), "identity": format!("{domain}/identity"), "notifications": format!("{domain}/notifications"), "sso": "", "cloudRegion": null, }, // Bitwarden uses this for the self-hosted servers to indicate the default push technology "push": { "pushTechnology": 0, "vapidPublicKey": null }, "featureStates": feature_states, "object": "config", })) } pub fn catchers() -> Vec { catchers![api_not_found] } #[catch(404)] fn api_not_found() -> Json { Json(json!({ "error": { "code": 404, "reason": "Not Found", "description": "The requested resource could not be found." } })) } async fn accept_org_invite( user: &User, mut member: Membership, reset_password_key: Option, conn: &DbConn, ) -> EmptyResult { if member.status != MembershipStatus::Invited as i32 { err!("User already accepted the invitation"); } member.status = MembershipStatus::Accepted as i32; member.reset_password_key = reset_password_key; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type OrgPolicy::check_user_allowed(&member, "join", conn).await?; member.save(conn).await?; if crate::CONFIG.mail_enabled() { let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { Some(org) => org, None => err!("Organization not found."), }; // User was invited to an organization, so they must be confirmed manually after acceptance mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) .await?; } Ok(()) } ================================================ FILE: src/api/core/organizations.rs ================================================ use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; use std::collections::{HashMap, HashSet}; use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ api::{ core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders}, db::{ models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, }, DbConn, }, mail, util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, CONFIG, }; pub fn routes() -> Vec { routes![ get_organization, create_organization, delete_organization, post_delete_organization, leave_organization, get_user_collections, get_org_collections, get_org_collections_details, get_org_collection_detail, get_collection_users, put_organization, post_organization, post_organization_collections, post_bulk_access_collections, post_organization_collection_update, put_organization_collection_update, delete_organization_collection, post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, get_org_details, get_org_domain_sso_verified, get_members, send_invite, reinvite_member, bulk_reinvite_members, confirm_invite, bulk_confirm_invite, accept_invite, get_org_user_mini_details, get_user, edit_member, put_member, delete_member, bulk_delete_member, post_org_import, list_policies, list_policies_token, get_master_password_policy, get_policy, put_policy, put_policy_vnext, get_plans, post_org_keys, get_organization_keys, get_organization_public_key, bulk_public_keys, revoke_member, bulk_revoke_members, restore_member, bulk_restore_members, get_groups, get_groups_details, post_groups, get_group, put_group, post_group, get_group_details, delete_group, post_delete_group, bulk_delete_groups, get_group_members, put_group_members, post_delete_group_member, put_reset_password_enrollment, get_reset_password_details, put_reset_password, get_org_export, api_key, rotate_api_key, get_billing_metadata, get_billing_warnings, get_auto_enroll_status, ] } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgData { billing_email: String, collection_name: String, key: String, name: String, keys: Option, #[allow(dead_code)] plan_type: NumberOrString, // Ignored, always use the same plan } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct OrganizationUpdateData { billing_email: String, name: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct FullCollectionData { name: String, groups: Vec, users: Vec, id: Option, external_id: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionGroupData { hide_passwords: bool, id: GroupId, read_only: bool, manage: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionMembershipData { hide_passwords: bool, id: MembershipId, read_only: bool, manage: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgKeyData { encrypted_private_key: String, public_key: String, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkGroupIds { ids: Vec, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkMembershipIds { ids: Vec, } #[post("/organizations", data = "")] async fn create_organization(headers: Headers, data: Json, conn: DbConn) -> JsonResult { if !CONFIG.is_org_creation_allowed(&headers.user.email) { err!("User not allowed to create organizations") } if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { err!( "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." ) } let data: OrgData = data.into_inner(); let (private_key, public_key) = if let Some(keys) = data.keys { (Some(keys.encrypted_private_key), Some(keys.public_key)) } else { (None, None) }; let org = Organization::new(data.name, &data.billing_email, private_key, public_key); let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None); member.akey = data.key; member.access_all = true; member.atype = MembershipType::Owner as i32; member.status = MembershipStatus::Confirmed as i32; org.save(&conn).await?; member.save(&conn).await?; collection.save(&conn).await?; Ok(Json(org.to_json())) } #[delete("/organizations/", data = "")] async fn delete_organization( org_id: OrganizationId, data: Json, headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); data.validate(&headers.user, true, &conn).await?; match Organization::find_by_uuid(&org_id, &conn).await { None => err!("Organization not found"), Some(org) => org.delete(&conn).await, } } #[post("/organizations//delete", data = "")] async fn post_delete_organization( org_id: OrganizationId, data: Json, headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { delete_organization(org_id, data, headers, conn).await } #[post("/organizations//leave")] async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbConn) -> EmptyResult { match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { None => err!("User not part of organization"), Some(member) => { if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { err!("The last owner can't leave") } log_event( EventType::OrganizationUserLeft as i32, &member.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; member.delete(&conn).await } } } #[get("/organizations/")] async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Organization::find_by_uuid(&org_id, &conn).await { Some(organization) => Ok(Json(organization.to_json())), None => err!("Can't find organization details"), } } #[put("/organizations/", data = "")] async fn put_organization( org_id: OrganizationId, headers: OwnerHeaders, data: Json, conn: DbConn, ) -> JsonResult { post_organization(org_id, headers, data, conn).await } #[post("/organizations/", data = "")] async fn post_organization( org_id: OrganizationId, headers: OwnerHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: OrganizationUpdateData = data.into_inner(); let Some(mut org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Organization not found") }; org.name = data.name; org.billing_email = data.billing_email.to_lowercase(); org.save(&conn).await?; log_event( EventType::OrganizationUpdated as i32, org_id.as_ref(), &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(org.to_json())) } // GET /api/collections?writeOnly=false #[get("/collections")] async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { Json(json!({ "data": Collection::find_by_user_uuid(headers.user.uuid, &conn).await .iter() .map(Collection::to_json) .collect::(), "object": "list", "continuationToken": null, })) } // Called during the SSO enrollment // The `identifier` should be the value returned by `get_org_domain_sso_verified` // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it #[get("/organizations//auto-enroll-status")] async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult { let org = if identifier == crate::sso::FAKE_IDENTIFIER { match Membership::find_main_user_org(&headers.user.uuid, &conn).await { Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await, None => None, } } else { Organization::find_by_uuid(&identifier.into(), &conn).await }; let (id, identifier, rp_auto_enroll) = match org { None => (get_uuid(), identifier.to_string(), false), Some(org) => ( org.uuid.to_string(), org.uuid.to_string(), OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &conn).await, ), }; Ok(Json(json!({ "id": id, "identifier": identifier, "resetPasswordEnabled": rp_auto_enroll, }))) } #[get("/organizations//collections")] async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } if !headers.membership.has_full_access() { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ "data": _get_org_collections(&org_id, &conn).await, "object": "list", "continuationToken": null, }))) } #[get("/organizations//collections/details")] async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { err!("User is not part of organization") }; // get all collection memberships for the current organization let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); // check if current user has full access to the organization (either directly or via any group) let has_full_access_to_org = member.has_full_access() || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); // Get all admins, owners and managers who can manage/access all // Those are currently not listed in the col_users but need to be listed too. let manage_all_members: Vec = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) .await .into_iter() .map(|member| { json!({ "id": member.uuid, "readOnly": false, "hidePasswords": false, "manage": true, }) }) .collect(); let mut data = Vec::new(); for col in Collection::find_by_organization(&org_id, &conn).await { // check whether the current user has access to the given collection let assigned = has_full_access_to_org || CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await || (CONFIG.org_groups_enabled() && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); // If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection if !assigned { continue; } // get the users assigned directly to the given collection let mut users: Vec = col_users .iter() .filter(|collection_member| collection_member.collection_uuid == col.uuid) .map(|collection_member| { collection_member.to_json_details_for_member( *membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), ) }) .collect(); users.extend_from_slice(&manage_all_members); // get the group details for the given collection let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&col.uuid, &conn) .await .iter() .map(|collection_group| collection_group.to_json_details_for_group()) .collect() } else { Vec::with_capacity(0) }; let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); json_object["object"] = json!("collectionAccessDetails"); json_object["unmanaged"] = json!(false); data.push(json_object) } Ok(Json(json!({ "data": data, "object": "list", "continuationToken": null, }))) } async fn _get_org_collections(org_id: &OrganizationId, conn: &DbConn) -> Value { Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() } #[post("/organizations//collections", data = "")] async fn post_organization_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: FullCollectionData = data.into_inner(); let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Can't find organization details") }; let collection = Collection::new(org.uuid, data.name, data.external_id); collection.save(&conn).await?; log_event( EventType::CollectionCreated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; for group in data.groups { CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) .save(&conn) .await?; } for user in data.users { let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { err!("User is not part of organization") }; if member.access_all { continue; } CollectionUser::save( &member.user_uuid, &collection.uuid, user.read_only, user.hide_passwords, user.manage, &conn, ) .await?; } if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, false, &conn).await?; } Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkCollectionAccessData { collection_ids: Vec, groups: Vec, users: Vec, } #[post("/organizations//collections/bulk-access", data = "", rank = 1)] async fn post_bulk_access_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> EmptyResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: BulkCollectionAccessData = data.into_inner(); if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") }; for col_id in data.collection_ids { let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found") }; if !collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await { err!("Collection not found", "The current user isn't a manager for this collection") } // update collection modification date collection.save(&conn).await?; log_event( EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; CollectionGroup::delete_all_by_collection(&col_id, &conn).await?; for group in &data.groups { CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage) .save(&conn) .await?; } CollectionUser::delete_all_by_collection(&col_id, &conn).await?; for user in &data.users { let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { err!("User is not part of organization") }; if member.access_all { continue; } CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) .await?; } } Ok(()) } #[put("/organizations//collections/", data = "")] async fn put_organization_collection_update( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, data: Json, conn: DbConn, ) -> JsonResult { post_organization_collection_update(org_id, col_id, headers, data, conn).await } #[post("/organizations//collections/", data = "", rank = 2)] async fn post_organization_collection_update( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: FullCollectionData = data.into_inner(); if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") }; let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found") }; collection.name = data.name; collection.external_id = match data.external_id { Some(external_id) if !external_id.trim().is_empty() => Some(external_id), _ => None, }; collection.save(&conn).await?; log_event( EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; CollectionGroup::delete_all_by_collection(&col_id, &conn).await?; for group in data.groups { CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage) .save(&conn) .await?; } CollectionUser::delete_all_by_collection(&col_id, &conn).await?; for user in data.users { let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { err!("User is not part of organization") }; if member.access_all { continue; } CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) .await?; } Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await)) } async fn _delete_organization_collection( org_id: &OrganizationId, col_id: &CollectionId, headers: &ManagerHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { err!("Collection not found", "Collection does not exist or does not belong to this organization") }; log_event( EventType::CollectionDeleted as i32, &collection.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; collection.delete(conn).await } #[delete("/organizations//collections/")] async fn delete_organization_collection( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, conn: DbConn, ) -> EmptyResult { _delete_organization_collection(&org_id, &col_id, &headers, &conn).await } #[post("/organizations//collections//delete")] async fn post_organization_collection_delete( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, conn: DbConn, ) -> EmptyResult { _delete_organization_collection(&org_id, &col_id, &headers, &conn).await } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkCollectionIds { ids: Vec, } #[delete("/organizations//collections", data = "")] async fn bulk_delete_organization_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> EmptyResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: BulkCollectionIds = data.into_inner(); let collections = data.ids; let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; for col_id in collections { _delete_organization_collection(&org_id, &col_id, &headers, &conn).await? } Ok(()) } #[get("/organizations//collections//details")] async fn get_org_collection_detail( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Collection::find_by_uuid_and_user(&col_id, headers.user.uuid.clone(), &conn).await { None => err!("Collection not found"), Some(collection) => { if collection.org_uuid != org_id { err!("Collection is not owned by organization") } let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { err!("User is not part of organization") }; let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&collection.uuid, &conn) .await .iter() .map(|collection_group| collection_group.to_json_details_for_group()) .collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::with_capacity(0) }; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn) .await .into_iter() .map(|m| (m.uuid, m.atype)) .collect(); let users: Vec = CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn) .await .iter() .map(|collection_member| { collection_member.to_json_details_for_member( *membership_type .get(&collection_member.membership_uuid) .unwrap_or(&(MembershipType::User as i32)), ) }) .collect(); let assigned = Collection::can_access_collection(&member, &collection.uuid, &conn).await; let mut json_object = collection.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); json_object["object"] = json!("collectionAccessDetails"); Ok(Json(json_object)) } } } #[get("/organizations//collections//users")] async fn get_collection_users( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } // Get org and collection, check that collection is from org let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found in Organization") }; let mut member_list = Vec::new(); for col_user in CollectionUser::find_by_collection(&collection.uuid, &conn).await { member_list.push( Membership::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) .await .unwrap() .to_json_user_access_restrictions(&col_user), ); } Ok(Json(json!(member_list))) } #[derive(FromForm)] struct OrgIdData { #[field(name = "organizationId")] organization_id: OrganizationId, } #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } if !headers.membership.has_full_access() { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ "data": _get_org_details(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, "object": "list", "continuationToken": null, }))) } async fn _get_org_details( org_id: &OrganizationId, host: &str, user_id: &UserId, conn: &DbConn, ) -> Result { let ciphers = Cipher::find_by_org(org_id, conn).await; let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?); } Ok(json!(ciphers_json)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgDomainDetails { email: String, } // Returning a Domain/Organization here allow to prefill it and prevent prompting the user // So we either return an Org name associated to the user or a dummy value. // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` #[post("/organizations/domain/sso/verified", data = "")] async fn get_org_domain_sso_verified(data: Json, conn: DbConn) -> JsonResult { let data: OrgDomainDetails = data.into_inner(); let identifiers = match Organization::find_org_user_email(&data.email, &conn) .await .into_iter() .map(|o| (o.name, o.uuid.to_string())) .collect::>() { v if !v.is_empty() => v, _ => vec![(crate::sso::FAKE_IDENTIFIER.to_string(), crate::sso::FAKE_IDENTIFIER.to_string())], }; Ok(Json(json!({ "object": "list", "data": identifiers.into_iter().map(|(name, identifier)| json!({ "organizationName": name, // appear unused "organizationIdentifier": identifier, "domainName": CONFIG.domain(), // appear unused })).collect::>() }))) } #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] include_collections: Option, #[field(name = "includeGroups")] include_groups: Option, } #[get("/organizations//users?")] async fn get_members( data: GetOrgUserData, org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn, ) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let mut users_json = Vec::new(); for u in Membership::find_by_org(&org_id, &conn).await { users_json.push( u.to_json_user_details( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), &conn, ) .await, ); } Ok(Json(json!({ "data": users_json, "object": "list", "continuationToken": null, }))) } #[post("/organizations//keys", data = "")] async fn post_org_keys( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: OrgKeyData = data.into_inner(); let mut org = match Organization::find_by_uuid(&org_id, &conn).await { Some(organization) => { if organization.private_key.is_some() && organization.public_key.is_some() { err!("Organization Keys already exist") } organization } None => err!("Can't find organization details"), }; org.private_key = Some(data.encrypted_private_key); org.public_key = Some(data.public_key); org.save(&conn).await?; Ok(Json(json!({ "object": "organizationKeys", "publicKey": org.public_key, "privateKey": org.private_key, }))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { emails: Vec, groups: Vec, r#type: NumberOrString, collections: Option>, #[serde(default)] permissions: HashMap, } #[post("/organizations//users/invite", data = "")] async fn send_invite( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: InviteData = data.into_inner(); // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); // Membership::from_str will convert custom (4) to manager (3) let new_type = match MembershipType::from_str(raw_type) { Some(new_type) => new_type as i32, None => err!("Invalid type"), }; if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can invite Managers, Admins or Owners") } // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); let mut user_created: bool = false; for email in data.emails.iter() { let mut member_status = MembershipStatus::Invited as i32; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { 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() { Invitation::new(email).save(&conn).await?; } let mut new_user = User::new(email, None); new_user.save(&conn).await?; user_created = true; new_user } Some(user) => { if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {email}")) } else { // automatically accept existing users if mail is disabled if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { member_status = MembershipStatus::Accepted as i32; } user } } }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); new_member.access_all = access_all; new_member.atype = new_type; new_member.status = member_status; new_member.save(&conn).await?; if CONFIG.mail_enabled() { let org_name = match Organization::find_by_uuid(&org_id, &conn).await { Some(org) => org.name, None => err!("Error looking up organization"), }; if let Err(e) = mail::send_invite( &user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(headers.user.email.clone()), ) .await { // Upon error delete the user, invite and org member records when needed if user_created { user.delete(&conn).await?; } else { new_member.delete(&conn).await?; } err!(format!("Error sending invite: {e:?} ")); } } log_event( EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; // If no accessAll, add the collections received if !access_all { for col in data.collections.iter().flatten() { match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { None => err!("Collection not found in Organization"), Some(collection) => { CollectionUser::save( &user.uuid, &collection.uuid, col.read_only, col.hide_passwords, col.manage, &conn, ) .await?; } } } } for group_id in data.groups.iter() { let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } } Ok(()) } #[post("/organizations//users/reinvite", data = "")] async fn bulk_reinvite_members( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )) } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[post("/organizations//users//reinvite")] async fn reinvite_member( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } _reinvite_member(&org_id, &member_id, &headers.user.email, &conn).await } async fn _reinvite_member( org_id: &OrganizationId, member_id: &MembershipId, invited_by_email: &str, conn: &DbConn, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") }; if member.status != MembershipStatus::Invited as i32 { err!("The user is already accepted or confirmed to the organization") } let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else { err!("User not found.") }; if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { err!("Invitations are not allowed.") } let org_name = match Organization::find_by_uuid(org_id, conn).await { Some(org) => org.name, None => err!("Error looking up organization."), }; if CONFIG.mail_enabled() { mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; } else { Invitation::take(&user.email, conn).await; let mut member = member; member.status = MembershipStatus::Accepted as i32; member.save(conn).await?; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AcceptData { token: String, reset_password_key: Option, } #[post("/organizations//users//accept", data = "")] async fn accept_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: Headers, conn: DbConn, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); let claims = decode_invite(&data.token)?; // Don't allow other users from accepting an invitation. if !claims.email.eq(&headers.user.email) { err!("Invitation was issued to a different account", "Claim does not match user_id") } // If a claim org_id does not match the one in from the URI, something is wrong. if !claims.org_id.eq(&org_id) { err!("Error accepting the invitation", "Claim does not match the org_id") } // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. if !claims.member_id.eq(&member_id) { err!("Error accepting the invitation", "Claim does not match the member_id") } let member_id = &claims.member_id; Invitation::take(&claims.email, &conn).await; // skip invitation logic when we were invited via the /admin panel if **member_id != FAKE_ADMIN_UUID { let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else { err!("Error accepting the invitation") }; let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &conn).await { true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), true => data.reset_password_key, false => None, }; // In case the user was invited before the mail was saved in db. member.invited_by_email = member.invited_by_email.or(claims.invited_by_email); accept_org_invite(&headers.user, member, reset_password_key, &conn).await?; } else if CONFIG.mail_enabled() { // User was invited from /admin, so they are automatically confirmed let org_name = CONFIG.invitation_org_name(); mail::send_invite_confirmed(&claims.email, &org_name).await?; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ConfirmData { id: Option, key: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkConfirmData { keys: Option>, } #[post("/organizations//users/confirm", data = "")] async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.keys { Some(keys) => { for invite in keys { let member_id = invite.id.unwrap(); let user_key = invite.key.unwrap_or_default(); let err_msg = match _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )); } } None => error!("No keys to confirm"), } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[post("/organizations//users//confirm", data = "")] async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: AdminHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); let user_key = data.key.unwrap_or_default(); _confirm_invite(&org_id, &member_id, &user_key, &headers, &conn, &nt).await } async fn _confirm_invite( org_id: &OrganizationId, member_id: &MembershipId, key: &str, headers: &AdminHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } if key.is_empty() || member_id.is_empty() { err!("Key or UserId is not set, unable to process request"); } let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The specified user isn't a member of the organization") }; if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can confirm Managers, Admins or Owners") } if member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") } member_to_confirm.status = MembershipStatus::Confirmed as i32; member_to_confirm.akey = key.to_string(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; if CONFIG.mail_enabled() { let org_name = match Organization::find_by_uuid(org_id, conn).await { Some(org) => org.name, None => err!("Error looking up organization."), }; let address = match User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { Some(user) => user.email, None => err!("Error looking up user."), }; mail::send_invite_confirmed(&address, &org_name).await?; } let save_result = member_to_confirm.save(conn).await; if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await; } save_result } #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let mut members_json = Vec::new(); for m in Membership::find_by_org(&org_id, &conn).await { members_json.push(m.to_json_mini_details(&conn).await); } Ok(Json(json!({ "data": members_json, "object": "list", "continuationToken": null, }))) } #[get("/organizations//users/?", rank = 2)] async fn get_user( org_id: OrganizationId, member_id: MembershipId, data: GetOrgUserData, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(user) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("The specified user isn't a member of the organization") }; // In this case, when groups are requested we also need to include collections. // Else these will not be shown in the interface, and could lead to missing collections when saved. let include_groups = data.include_groups.unwrap_or(false); Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EditUserData { r#type: NumberOrString, collections: Option>, groups: Option>, #[serde(default)] permissions: HashMap, } #[put("/organizations//users/", data = "", rank = 1)] async fn put_member( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { edit_member(org_id, member_id, data, headers, conn).await } #[post("/organizations//users/", data = "", rank = 1)] async fn edit_member( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: EditUserData = data.into_inner(); // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); // MembershipType::from_str will convert custom (4) to manager (3) let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); let mut member_to_edit = match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { Some(member) => member, None => err!("The specified user isn't member of the organization"), }; if new_type != member_to_edit.atype && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) && headers.membership_type != MembershipType::Owner { err!("Only Owners can grant and remove Admin or Owner privileges") } if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only Owners can edit Owner users") } if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner && member_to_edit.status == MembershipStatus::Confirmed as i32 { // Removing owner permission, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { err!("Can't delete the last owner") } } member_to_edit.access_all = access_all; member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; // Delete all the odd collections for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { c.delete(&conn).await?; } // If no accessAll, add the collections received if !access_all { for col in data.collections.iter().flatten() { match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { None => err!("Collection not found in Organization"), Some(collection) => { CollectionUser::save( &member_to_edit.user_uuid, &collection.uuid, col.read_only, col.hide_passwords, col.manage, &conn, ) .await?; } } } } GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; for group_id in data.groups.iter().flatten() { let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); group_entry.save(&conn).await?; } log_event( EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; member_to_edit.save(&conn).await } #[delete("/organizations//users", data = "")] async fn bulk_delete_member( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match _delete_member(&org_id, &member_id, &headers, &conn, &nt).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )) } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[delete("/organizations//users/")] async fn delete_member( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { _delete_member(&org_id, &member_id, &headers, &conn, &nt).await } async fn _delete_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(member_to_delete) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("User to delete isn't member of the organization") }; if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can delete Admins or Owners") } if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32 { // Removing owner, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { err!("Can't delete the last owner") } } log_event( EventType::OrganizationUserRemoved as i32, &member_to_delete.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await; } member_to_delete.delete(conn).await } #[post("/organizations//users/public-keys", data = "")] async fn bulk_public_keys( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); // Check all received Membership UUID's and find the matching User to retrieve the public-key. // If the user does not exists, just ignore it, and do not return any information regarding that Membership UUID. // The web-vault will then ignore that user for the following steps. for member_id in data.ids { match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { Some(member) => match User::find_by_uuid(&member.user_uuid, &conn).await { Some(user) => bulk_response.push(json!( { "object": "organizationUserPublicKeyResponseModel", "id": member_id, "userId": user.uuid, "key": user.public_key } )), None => debug!("User doesn't exist"), }, None => debug!("Membership doesn't exist"), } } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } use super::ciphers::update_cipher_from_data; use super::ciphers::CipherData; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, collections: Vec, collection_relationships: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RelationsData { // Cipher index key: usize, // Collection index value: usize, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 #[post("/ciphers/import-organization?", data = "")] async fn post_org_import( query: OrgIdData, data: Json, headers: OrgMemberHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let org_id = query.organization_id; if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: ImportData = data.into_inner(); // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; let existing_collections: HashSet> = Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect(); let mut collections: Vec = Vec::with_capacity(data.collections.len()); for col in data.collections { let collection_uuid = if existing_collections.contains(&col.id) { let col_id = col.id.unwrap(); // When not an Owner or Admin, check if the member is allowed to access the collection. if headers.membership.atype < MembershipType::Admin && !Collection::can_access_collection(&headers.membership, &col_id, &conn).await { err!(Compact, "The current user isn't allowed to manage this collection") } col_id } else { // We do not allow users or managers which can not manage all collections to create new collections // If there is any collection other than an existing import collection, abort the import. if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { err!(Compact, "The current user isn't allowed to create new collections") } let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&conn).await?; new_collection.uuid }; collections.push(collection_uuid); } // Read the relations between collections and ciphers // Ciphers can be in multiple collections at the same time let mut relations = Vec::with_capacity(data.collection_relationships.len()); for relation in data.collection_relationships { relations.push((relation.key, relation.value)); } let headers: Headers = headers.into(); let mut ciphers: Vec = Vec::with_capacity(data.ciphers.len()); for mut cipher_data in data.ciphers { // Always clear folder_id's via an organization import cipher_data.folder_id = None; let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, &headers, Some(collections.clone()), &conn, &nt, UpdateType::None, ) .await .ok(); ciphers.push(cipher.uuid); } // Assign the collections for (cipher_index, col_index) in relations { let cipher_id = &ciphers[cipher_index]; let col_id = &collections[col_index]; CollectionCipher::save(cipher_id, col_id, &conn).await?; } let mut user = headers.user; user.update_revision(&conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] struct BulkCollectionsData { organization_id: OrganizationId, cipher_ids: Vec, collection_ids: HashSet, remove_collections: bool, } // This endpoint is only reachable via the organization view, therefore this endpoint is located here // Also Bitwarden does not send out Notifications for these changes, it only does this for individual cipher collection updates #[post("/ciphers/bulk-collections", data = "")] async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); // Get all the collection available to the user in one query // Also filter based upon the provided collections let user_collections: HashMap = Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) .await .into_iter() .filter_map(|c| { if data.collection_ids.contains(&c.uuid) { Some((c.uuid.clone(), c)) } else { None } }) .collect(); // Verify if all the collections requested exists and are writeable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } for cipher_id in data.cipher_ids.iter() { // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await { if cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. if data.remove_collections { for collection in &data.collection_ids { CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; } } else { for collection in &data.collection_ids { CollectionCipher::save(&cipher.uuid, collection, &conn).await?; } } } }; } Ok(()) } #[get("/organizations//policies")] async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let policies = OrgPolicy::find_by_org(&org_id, &conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ "data": policies_json, "object": "list", "continuationToken": null }))) } #[get("/organizations//policies/token?")] async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) -> JsonResult { let invite = decode_invite(token)?; if invite.org_id != org_id { err!("Token doesn't match request organization"); } // exit early when we have been invited via /admin panel if org_id.as_ref() == FAKE_ADMIN_UUID { return Ok(Json(json!({}))); } // TODO: We receive the invite token as ?token=<>, validate it contains the org id let policies = OrgPolicy::find_by_org(&org_id, &conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ "data": policies_json, "object": "list", "continuationToken": null }))) } // Called during the SSO enrollment. // Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, conn: DbConn) -> JsonResult { let policy = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { let (enabled, data) = match CONFIG.sso_master_password_policy_value() { Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), _ => (false, "null".to_string()), }; OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data) }); Ok(Json(policy.to_json())) } #[get("/organizations//policies/", rank = 2)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()), }; Ok(Json(policy.to_json())) } #[derive(Deserialize)] struct PolicyData { enabled: bool, data: Option, } #[put("/organizations//policies/", data = "")] async fn put_policy( org_id: OrganizationId, pol_type: i32, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PolicyData = data.into_inner(); let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. // We put this behind a config option to prevent breaking current installation. // Maybe we want to enable this by default in the future, but currently it is disabled by default. if CONFIG.enforce_single_org_with_reset_pw_policy() { if pol_type_enum == OrgPolicyType::ResetPassword && data.enabled { let single_org_policy_enabled = match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { Some(p) => p.enabled, None => false, }; if !single_org_policy_enabled { err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") } } // Also prevent the Single Org Policy to be disabled if the Reset Password policy is enabled if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled { let reset_pw_policy_enabled = match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::ResetPassword, &conn).await { Some(p) => p.enabled, None => false, }; if reset_pw_policy_enabled { err!("Account recovery policy is enabled. It is not allowed to disable this policy.") } } } // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await?; } // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { for mut member in Membership::find_by_org(&org_id, &conn).await.into_iter() { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. if member.atype < MembershipType::Admin && member.status != MembershipStatus::Invited as i32 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await > 0 { if CONFIG.mail_enabled() { let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap(); mail::send_single_org_removed_from_org(&user.email, &org.name).await?; } log_event( EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; member.revoke(); member.save(&conn).await?; } } } let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()), }; policy.enabled = data.enabled; policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; log_event( EventType::PolicyUpdated as i32, policy.uuid.as_ref(), &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(policy.to_json())) } #[derive(Deserialize)] struct PolicyDataVnext { policy: PolicyData, // Ignore metadata for now as we do not yet support this // "metadata": { // "defaultUserCollectionName": "2.xx|xx==|xx=" // } } #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { let data: PolicyDataVnext = data.into_inner(); let policy: PolicyData = data.policy; put_policy(org_id, pol_type, Json(policy), headers, conn).await } #[get("/plans")] fn get_plans() -> Json { // Respond with a minimal json just enough to allow the creation of an new organization. Json(json!({ "object": "list", "data": [{ "object": "plan", "type": 0, "product": 0, "name": "Free", "nameLocalizationKey": "planNameFree", "bitwardenProduct": 0, "maxUsers": 0, "descriptionLocalizationKey": "planDescFree" },{ "object": "plan", "type": 0, "product": 1, "name": "Free", "nameLocalizationKey": "planNameFree", "bitwardenProduct": 1, "maxUsers": 0, "descriptionLocalizationKey": "planDescFree" }], "continuationToken": null })) } #[get("/organizations/<_org_id>/billing/metadata")] fn get_billing_metadata(_org_id: OrganizationId, _headers: Headers) -> Json { // Prevent a 404 error, which also causes Javascript errors. Json(_empty_data_json()) } #[get("/organizations/<_org_id>/billing/vnext/warnings")] fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json { Json(json!({ "freeTrial":null, "inactiveSubscription":null, "resellerRenewal":null, "taxId":null, })) } fn _empty_data_json() -> Value { json!({ "object": "list", "data": [], "continuationToken": null }) } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkRevokeMembershipIds { ids: Option>, } #[put("/organizations//users//revoke")] async fn revoke_member( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { _revoke_member(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/revoke", data = "")] async fn bulk_revoke_members( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.ids { Some(members) => { for member_id in members { let err_msg = match _revoke_member(&org_id, &member_id, &headers, &conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", "id": member_id, "error": err_msg } )); } } None => error!("No users to revoke"), } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } async fn _revoke_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status > MembershipStatus::Revoked as i32 => { if member.user_uuid == headers.user.uuid { err!("You cannot revoke yourself") } if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only owners can revoke other owners") } if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { err!("Organization must have at least one confirmed owner") } member.revoke(); member.save(conn).await?; log_event( EventType::OrganizationUserRevoked as i32, &member.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Some(_) => err!("User is already revoked"), None => err!("User not found in organization"), } Ok(()) } #[put("/organizations//users//restore")] async fn restore_member( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { _restore_member(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/restore", data = "")] async fn bulk_restore_members( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match _restore_member(&org_id, &member_id, &headers, &conn).await { Ok(_) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", "id": member_id, "error": err_msg } )); } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } async fn _restore_member( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { if member.user_uuid == headers.user.uuid { err!("You cannot restore yourself") } if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only owners can restore other owners") } member.restore(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check need to be done after restoring to work with the correct status OrgPolicy::check_user_allowed(&member, "restore", conn).await?; member.save(conn).await?; log_event( EventType::OrganizationUserRestored as i32, &member.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Some(_) => err!("User is already active"), None => err!("User not found in organization"), } Ok(()) } async fn get_groups_data( details: bool, org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn, ) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let groups: Vec = if CONFIG.org_groups_enabled() { let groups = Group::find_by_organization(&org_id, &conn).await; let mut groups_json = Vec::with_capacity(groups.len()); if details { for g in groups { groups_json.push(g.to_json_details(&conn).await) } } else { for g in groups { groups_json.push(g.to_json()) } } groups_json } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::with_capacity(0) }; Ok(Json(json!({ "data": groups, "object": "list", "continuationToken": null, }))) } #[get("/organizations//groups")] async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { get_groups_data(false, org_id, headers, conn).await } #[get("/organizations//groups/details", rank = 1)] async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { get_groups_data(true, org_id, headers, conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GroupRequest { name: String, #[serde(default)] access_all: bool, external_id: Option, collections: Vec, users: Vec, } impl GroupRequest { pub fn to_group(&self, org_uuid: &OrganizationId) -> Group { Group::new(org_uuid.clone(), self.name.clone(), self.access_all, self.external_id.clone()) } pub fn update_group(&self, mut group: Group) -> Group { group.name.clone_from(&self.name); group.access_all = self.access_all; // Group Updates do not support changing the external_id // These input fields are in a disabled state, and can only be updated/added via ldap_import group } } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct CollectionData { id: CollectionId, read_only: bool, hide_passwords: bool, manage: bool, } impl CollectionData { pub fn to_collection_group(&self, groups_uuid: GroupId) -> CollectionGroup { CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords, self.manage) } } #[post("/organizations//groups/", data = "")] async fn post_group( org_id: OrganizationId, group_id: GroupId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { put_group(org_id, group_id, data, headers, conn).await } #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, headers: AdminHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let group_request = data.into_inner(); let group = group_request.to_group(&org_id); log_event( EventType::GroupCreated as i32, &group.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; add_update_group(group, group_request.collections, group_request.users, org_id, &headers, &conn).await } #[put("/organizations//groups/", data = "")] async fn put_group( org_id: OrganizationId, group_id: GroupId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; let group_request = data.into_inner(); let updated_group = group_request.update_group(group); CollectionGroup::delete_all_by_group(&group_id, &conn).await?; GroupUser::delete_all_by_group(&group_id, &conn).await?; log_event( EventType::GroupUpdated as i32, &updated_group.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await } async fn add_update_group( mut group: Group, collections: Vec, members: Vec, org_id: OrganizationId, headers: &AdminHeaders, conn: &DbConn, ) -> JsonResult { group.save(conn).await?; for col_selection in collections { let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); collection_group.save(conn).await?; } for assigned_member in members { let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone()); user_entry.save(conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, &assigned_member, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Ok(Json(json!({ "id": group.uuid, "organizationId": group.organizations_uuid, "name": group.name, "accessAll": group.access_all, "externalId": group.external_id, "object": "group" }))) } #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; Ok(Json(group.to_json_details(&conn).await)) } #[post("/organizations//groups//delete")] async fn post_delete_group( org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { _delete_group(&org_id, &group_id, &headers, &conn).await } #[delete("/organizations//groups/")] async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { _delete_group(&org_id, &group_id, &headers, &conn).await } async fn _delete_group( org_id: &OrganizationId, group_id: &GroupId, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; log_event( EventType::GroupDeleted as i32, &group.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; group.delete(conn).await } #[delete("/organizations//groups", data = "")] async fn bulk_delete_groups( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let data: BulkGroupIds = data.into_inner(); for group_id in data.ids { _delete_group(&org_id, &group_id, &headers, &conn).await? } Ok(()) } #[get("/organizations//groups/", rank = 2)] async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; Ok(Json(group.to_json())) } #[get("/organizations//groups//users")] async fn get_group_members( org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") }; let group_members: Vec = GroupUser::find_by_group(&group_id, &conn) .await .iter() .map(|entry| entry.users_organizations_uuid.clone()) .collect(); Ok(Json(json!(group_members))) } #[put("/organizations//groups//users", data = "")] async fn put_group_members( org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, data: Json>, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") }; GroupUser::delete_all_by_group(&group_id, &conn).await?; let assigned_members = data.into_inner(); for assigned_member in assigned_members { let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone()); user_entry.save(&conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, &assigned_member, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; } Ok(()) } #[post("/organizations//groups//delete-user/")] async fn post_delete_group_member( org_id: OrganizationId, group_id: GroupId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() { err!("User could not be found or does not belong to the organization."); } if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { err!("Group could not be found or does not belong to the organization."); } log_event( EventType::OrganizationUserUpdatedGroups as i32, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserResetPasswordEnrollmentRequest { reset_password_key: Option, master_password_hash: Option, otp: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserResetPasswordRequest { new_master_password_hash: String, key: String, } // Upstream reports this is the renamed endpoint instead of `/keys` // But the clients do not seem to use this at all // Just add it here in case they will #[get("/organizations//public-key")] async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Organization not found") }; Ok(Json(json!({ "object": "organizationPublicKey", "publicKey": org.public_key, }))) } // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 #[get("/organizations//keys")] async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { get_organization_public_key(org_id, headers, conn).await } #[put("/organizations//users//reset-password", data = "")] async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, data: Json, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Required organization not found") }; let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org.uuid, &conn).await else { err!("User to reset isn't member of required organization") }; let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; if member.reset_password_key.is_none() { err!("Password reset not or not correctly enrolled"); } if member.status != (MembershipStatus::Confirmed as i32) { err!("Organization user must be confirmed for password reset functionality"); } // Sending email before resetting password to ensure working email configuration and the resulting // user notification. Also this might add some protection against security flaws and misuse if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { err!(format!("Error sending user reset password email: {e:#?}")); } let reset_request = data.into_inner(); let mut user = user; user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None); user.save(&conn).await?; nt.send_logout(&user, None, &conn).await; log_event( EventType::OrganizationUserAdminResetPassword as i32, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(()) } #[get("/organizations//users//reset-password-details")] async fn get_reset_password_details( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Required organization not found") }; let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("User to reset isn't member of required organization") }; let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190 Ok(Json(json!({ "object": "organizationUserResetPasswordDetails", "organizationUserId": member_id, "kdf": user.client_kdf_type, "kdfIterations": user.client_kdf_iter, "kdfMemory": user.client_kdf_memory, "kdfParallelism": user.client_kdf_parallelism, "resetPasswordKey": member.reset_password_key, "encryptedPrivateKey": org.private_key, }))) } async fn check_reset_password_applicable_and_permissions( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { check_reset_password_applicable(org_id, conn).await?; let Some(target_user) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("Reset target user not found") }; // Resetting user must be higher/equal to user to reset match headers.membership_type { MembershipType::Owner => Ok(()), MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()), _ => err!("No permission to reset this user's password"), } } async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Password reset is not supported on an email-disabled instance."); } let Some(policy) = OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await else { err!("Policy not found") }; if !policy.enabled { err!("Reset password policy not enabled"); } Ok(()) } #[put("/organizations//users//reset-password-enrollment", data = "")] async fn put_reset_password_enrollment( org_id: OrganizationId, member_id: MembershipId, headers: Headers, data: Json, conn: DbConn, ) -> EmptyResult { let Some(mut member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { err!("User to enroll isn't member of required organization") }; check_reset_password_applicable(&org_id, &conn).await?; let reset_request = data.into_inner(); let reset_password_key = match reset_request.reset_password_key { None => None, Some(ref key) if key.is_empty() => None, Some(key) => Some(key), }; if reset_password_key.is_none() && OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &conn).await { err!("Reset password can't be withdrawn due to an enterprise policy"); } if reset_password_key.is_some() { PasswordOrOtpData { master_password_hash: reset_request.master_password_hash, otp: reset_request.otp, } .validate(&headers.user, true, &conn) .await?; } member.reset_password_key = reset_password_key; member.save(&conn).await?; let log_id = if member.reset_password_key.is_some() { EventType::OrganizationUserResetPasswordEnroll as i32 } else { EventType::OrganizationUserResetPasswordWithdraw as i32 }; log_event(log_id, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(()) } // NOTE: It seems clients can't handle uppercase-first keys!! // We need to convert all keys so they have the first character to be a lowercase. // Else the export will be just an empty JSON file. // We currently only support exports by members of the Admin or Owner status. // Vaultwarden does not yet support exporting only managed collections! // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } Ok(Json(json!({ "collections": convert_json_key_lcase_first(_get_org_collections(&org_id, &conn).await), "ciphers": convert_json_key_lcase_first(_get_org_details(&org_id, &headers.host, &headers.user.uuid, &conn).await?), }))) } async fn _api_key( org_id: &OrganizationId, data: Json, rotate: bool, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; // Validate the admin users password/otp data.validate(&user, true, &conn).await?; let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { Some(mut org_api_key) => { if rotate { org_api_key.api_key = crate::crypto::generate_api_key(); org_api_key.revision_date = chrono::Utc::now().naive_utc(); org_api_key.save(&conn).await.expect("Error rotating organization API Key"); } org_api_key } None => { let api_key = crate::crypto::generate_api_key(); let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key); new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); new_org_api_key } }; Ok(Json(json!({ "apiKey": org_api_key.api_key, "revisionDate": crate::util::format_date(&org_api_key.revision_date), "object": "apiKey", }))) } #[post("/organizations//api-key", data = "")] async fn api_key( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { _api_key(&org_id, data, false, headers, conn).await } #[post("/organizations//rotate-api-key", data = "")] async fn rotate_api_key( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { _api_key(&org_id, data, true, headers, conn).await } ================================================ FILE: src/api/core/public.rs ================================================ use chrono::Utc; use rocket::{ request::{FromRequest, Outcome}, serde::json::Json, Request, Route, }; use std::collections::HashSet; use crate::{ api::EmptyResult, auth, db::{ models::{ Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, OrganizationApiKey, OrganizationId, User, }, DbConn, }, mail, CONFIG, }; pub fn routes() -> Vec { routes![ldap_import] } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgImportGroupData { name: String, external_id: String, member_external_ids: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgImportUserData { email: String, external_id: String, deleted: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgImportData { groups: Vec, members: Vec, overwrite_existing: bool, // largeImport: bool, // For now this will not be used, upstream uses this to prevent syncs of more then 2000 users or groups without the flag set. } #[post("/public/organization/import", data = "")] async fn ldap_import(data: Json, token: PublicToken, conn: DbConn) -> EmptyResult { // Most of the logic for this function can be found here // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs#L1203 let org_id = token.0; let data = data.into_inner(); for user_data in &data.members { let mut user_created: bool = false; if user_data.deleted { // If user is marked for deletion and it exists, revoke it if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { // Only revoke a user if it is not the last confirmed owner let revoked = if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { warn!("Can't revoke the last owner"); false } else { member.revoke() } } else { member.revoke() }; let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); if revoked || ext_modified { member.save(&conn).await?; } } // If user is part of the organization, restore it } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { let restored = member.restore(); let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); if restored || ext_modified { member.save(&conn).await?; } } else { // If user is not part of the organization let user = match User::find_by_mail(&user_data.email, &conn).await { Some(user) => user, // exists in vaultwarden None => { // User does not exist yet let mut new_user = User::new(&user_data.email, None); new_user.save(&conn).await?; if !CONFIG.mail_enabled() { Invitation::new(&new_user.email).save(&conn).await?; } user_created = true; new_user } }; let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { MembershipStatus::Invited as i32 } else { MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites }; let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &conn).await { Some(org) => (org.name, org.billing_email), None => err!("Error looking up organization"), }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); new_member.set_external_id(Some(user_data.external_id.clone())); new_member.access_all = false; new_member.atype = MembershipType::User as i32; new_member.status = member_status; new_member.save(&conn).await?; if CONFIG.mail_enabled() { if let Err(e) = mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await { // Upon error delete the user, invite and org member records when needed if user_created { user.delete(&conn).await?; } else { new_member.delete(&conn).await?; } err!(format!("Error sending invite: {e:?} ")); } } } } if CONFIG.org_groups_enabled() { for group_data in &data.groups { let group_uuid = match Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { Some(group) => group.uuid, None => { let mut group = Group::new( org_id.clone(), group_data.name.clone(), false, Some(group_data.external_id.clone()), ); group.save(&conn).await?; group.uuid } }; GroupUser::delete_all_by_group(&group_uuid, &conn).await?; for ext_id in &group_data.member_external_ids { if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await { let mut group_user = GroupUser::new(group_uuid.clone(), member.uuid.clone()); group_user.save(&conn).await?; } } } } else { warn!("Group support is disabled, groups will not be imported!"); } // If this flag is enabled, any user that isn't provided in the Users list will be removed (by default they will be kept unless they have Deleted == true) if data.overwrite_existing { // Generate a HashSet to quickly verify if a member is listed or not. let sync_members: HashSet = data.members.into_iter().map(|m| m.external_id).collect(); for member in Membership::find_by_org(&org_id, &conn).await { if let Some(ref user_external_id) = member.external_id { if !sync_members.contains(user_external_id) { if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { // Removing owner, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { warn!("Can't delete the last owner"); continue; } } member.delete(&conn).await?; } } } } Ok(()) } pub struct PublicToken(OrganizationId); #[rocket::async_trait] impl<'r> FromRequest<'r> for PublicToken { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); // Get access_token let access_token: &str = match headers.get_one("Authorization") { Some(a) => match a.rsplit("Bearer ").next() { Some(split) => split, None => err_handler!("No access token provided"), }, None => err_handler!("No access token provided"), }; // Check JWT token is valid and get device and user from it let Ok(claims) = auth::decode_api_org(access_token) else { err_handler!("Invalid claim") }; // Check if time is between claims.nbf and claims.exp let time_now = Utc::now().timestamp(); if time_now < claims.nbf { err_handler!("Token issued in the future"); } if time_now > claims.exp { err_handler!("Token expired"); } // Check if claims.iss is domain|claims.scope[0] let complete_host = format!("{}|{}", CONFIG.domain_origin(), claims.scope[0]); if complete_host != claims.iss { err_handler!("Token not issued by this server"); } // Check if claims.sub is org_api_key.uuid // Check if claims.client_sub is org_api_key.org_uuid let conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; let Some(org_id) = claims.client_id.strip_prefix("organization.") else { err_handler!("Malformed client_id") }; let org_id: OrganizationId = org_id.to_string().into(); let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await else { err_handler!("Invalid client_id") }; if org_api_key.org_uuid != claims.client_sub { err_handler!("Token not issued for this org"); } if org_api_key.uuid != claims.sub { err_handler!("Token not issued for this client"); } Outcome::Success(PublicToken(claims.client_sub)) } } ================================================ FILE: src/api/core/sends.rs ================================================ use std::{path::Path, sync::LazyLock, time::Duration}; use chrono::{DateTime, TimeDelta, Utc}; use num_traits::ToPrimitive; use rocket::{ form::Form, fs::{NamedFile, TempFile}, serde::json::Json, }; use serde_json::Value; use crate::{ api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, auth::{ClientIp, Headers, Host}, config::PathType, db::{ models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, DbConn, DbPool, }, util::{save_temp_file, NumberOrString}, CONFIG, }; const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available"; static ANON_PUSH_DEVICE: LazyLock = LazyLock::new(|| { let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z"); Device { uuid: String::from("00000000-0000-0000-0000-000000000000").into(), created_at: dt, updated_at: dt, user_uuid: String::from("00000000-0000-0000-0000-000000000000").into(), name: String::new(), atype: 14, // 14 == Unknown Browser push_uuid: Some(String::from("00000000-0000-0000-0000-000000000000").into()), push_token: None, refresh_token: String::new(), twofactor_remember: None, } }); // The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues const SIZE_525_MB: i64 = 550_502_400; pub fn routes() -> Vec { routes![ get_sends, get_send, post_send, post_send_file, post_access, post_access_file, put_send, delete_send, put_remove_password, download_send, post_send_file_v2, post_send_file_v2_data ] } pub async fn purge_sends(pool: DbPool) { debug!("Purging sends"); if let Ok(conn) = pool.get().await { Send::purge(&conn).await; } else { error!("Failed to get DB connection while purging sends") } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendData { r#type: i32, key: String, password: Option, max_access_count: Option, expiration_date: Option>, deletion_date: DateTime, disabled: bool, hide_email: Option, // Data field name: String, notes: Option, text: Option, file: Option, file_length: Option, // Used for key rotations pub id: Option, } /// Enforces the `Disable Send` policy. A non-owner/admin user belonging to /// an org with this policy enabled isn't allowed to create new Sends or /// modify existing ones, but is allowed to delete them. /// /// Ref: https://bitwarden.com/help/article/policies/#disable-send /// /// There is also a Vaultwarden-specific `sends_allowed` config setting that /// controls this policy globally. async fn enforce_disable_send_policy(headers: &Headers, conn: &DbConn) -> EmptyResult { let user_id = &headers.user.uuid; if !CONFIG.sends_allowed() || OrgPolicy::is_applicable_to_user(user_id, OrgPolicyType::DisableSend, None, conn).await { err!("Due to an Enterprise Policy, you are only able to delete an existing Send.") } Ok(()) } /// Enforces the `DisableHideEmail` option of the `Send Options` policy. /// A non-owner/admin user belonging to an org with this option enabled isn't /// allowed to hide their email address from the recipient of a Bitwarden Send, /// but is allowed to remove this option from an existing Send. /// /// Ref: https://bitwarden.com/help/article/policies/#send-options async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, conn: &DbConn) -> EmptyResult { let user_id = &headers.user.uuid; let hide_email = data.hide_email.unwrap_or(false); if hide_email && OrgPolicy::is_hide_email_disabled(user_id, conn).await { err!( "Due to an Enterprise Policy, you are not allowed to hide your email address \ from recipients when creating or editing a Send." ) } Ok(()) } fn create_send(data: SendData, user_id: UserId) -> ApiResult { let data_val = if data.r#type == SendType::Text as i32 { data.text } else if data.r#type == SendType::File as i32 { data.file } else { err!("Invalid Send type") }; let data_str = if let Some(mut d) = data_val { d.as_object_mut().and_then(|o| o.remove("response")); serde_json::to_string(&d)? } else { err!("Send data not provided"); }; if data.deletion_date > Utc::now() + TimeDelta::try_days(31).unwrap() { err!( "You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again." ); } let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); send.user_uuid = Some(user_id); send.notes = data.notes; send.max_access_count = match data.max_access_count { Some(m) => Some(m.into_i32()?), _ => None, }; send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.disabled = data.disabled; send.hide_email = data.hide_email; send.atype = data.r#type; send.set_password(data.password.as_deref()); Ok(send) } #[get("/sends")] async fn get_sends(headers: Headers, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); let sends_json: Vec = sends.await.iter().map(|s| s.to_json()).collect(); Json(json!({ "data": sends_json, "object": "list", "continuationToken": null })) } #[get("/sends/")] async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { match Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await { Some(send) => Ok(Json(send.to_json())), None => err!("Send not found", "Invalid send uuid or does not belong to user"), } } #[post("/sends", data = "")] async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; if data.r#type == SendType::File as i32 { err!("File sends should use /api/sends/file") } let mut send = create_send(data, headers.user.uuid)?; send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, &send, &send.update_users_revision(&conn).await, &headers.device, &conn, ) .await; Ok(Json(send.to_json())) } #[derive(FromForm)] struct UploadData<'f> { model: Json, data: TempFile<'f>, } #[derive(FromForm)] struct UploadDataV2<'f> { data: TempFile<'f>, } // @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2). // This method still exists to support older clients, probably need to remove it sometime. // Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167 // 2025: This endpoint doesn't seem to exists anymore in the latest version // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs #[post("/sends/file", format = "multipart/form-data", data = "")] async fn post_send_file(data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; let UploadData { model, data, } = data.into_inner(); let model = model.into_inner(); let Some(size) = data.len().to_i64() else { err!("Invalid send size"); }; if size < 0 { err!("Send size can't be negative") } enforce_disable_hide_email_policy(&model, &headers, &conn).await?; let size_limit = match CONFIG.user_send_limit() { Some(0) => err!("File uploads are disabled"), Some(limit_kb) => { let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else { err!("Existing sends overflow") }; let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { err!("Send size overflow"); }; if left <= 0 { err!("Send storage limit reached! Delete some sends to free up space") } i64::clamp(left, 0, SIZE_525_MB) } None => SIZE_525_MB, }; if size > size_limit { err!("Send storage limit exceeded with this file"); } let mut send = create_send(model, headers.user.uuid)?; if send.atype != SendType::File as i32 { err!("Send content is not a file"); } let file_id = crate::crypto::generate_send_file_id(); save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?; let mut data_value: Value = serde_json::from_str(&send.data)?; if let Some(o) = data_value.as_object_mut() { o.insert(String::from("id"), Value::String(file_id)); o.insert(String::from("size"), Value::Number(size.into())); o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size))); } send.data = serde_json::to_string(&data_value)?; // Save the changes in the database send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendCreate, &send, &send.update_users_revision(&conn).await, &headers.device, &conn, ) .await; Ok(Json(send.to_json())) } // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165 #[post("/sends/file/v2", data = "")] async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; let data = data.into_inner(); if data.r#type != SendType::File as i32 { err!("Send content is not a file"); } enforce_disable_hide_email_policy(&data, &headers, &conn).await?; let file_length = match &data.file_length { Some(m) => m.into_i64()?, _ => err!("Invalid send length"), }; if file_length < 0 { err!("Send size can't be negative") } let size_limit = match CONFIG.user_send_limit() { Some(0) => err!("File uploads are disabled"), Some(limit_kb) => { let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else { err!("Existing sends overflow") }; let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else { err!("Send size overflow"); }; if left <= 0 { err!("Send storage limit reached! Delete some sends to free up space") } i64::clamp(left, 0, SIZE_525_MB) } None => SIZE_525_MB, }; if file_length > size_limit { err!("Send storage limit exceeded with this file"); } let mut send = create_send(data, headers.user.uuid)?; let file_id = crate::crypto::generate_send_file_id(); let mut data_value: Value = serde_json::from_str(&send.data)?; if let Some(o) = data_value.as_object_mut() { o.insert(String::from("id"), Value::String(file_id.clone())); o.insert(String::from("size"), Value::Number(file_length.into())); o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); } send.data = serde_json::to_string(&data_value)?; send.save(&conn).await?; Ok(Json(json!({ "fileUploadType": 0, // 0 == Direct | 1 == Azure "object": "send-fileUpload", "url": format!("/sends/{}/file/{file_id}", send.uuid), "sendResponse": send.to_json() }))) } #[derive(Deserialize)] #[allow(non_snake_case)] pub struct SendFileData { id: SendFileId, size: u64, fileName: String, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 #[post("/sends//file/", format = "multipart/form-data", data = "")] async fn post_send_file_v2_data( send_id: SendId, file_id: SendFileId, data: Form>, headers: Headers, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { enforce_disable_send_policy(&headers, &conn).await?; let data = data.into_inner(); let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found. Unable to save the file.", "Invalid send uuid or does not belong to user.") }; if send.atype != SendType::File as i32 { err!("Send is not a file type send."); } let Ok(send_data) = serde_json::from_str::(&send.data) else { err!("Unable to decode send data as json.") }; match data.data.raw_name() { Some(raw_file_name) if raw_file_name.dangerous_unsafe_unsanitized_raw() == send_data.fileName // be less strict only if using CLI, cf. https://github.com/dani-garcia/vaultwarden/issues/5614 || (headers.device.is_cli() && send_data.fileName.ends_with(raw_file_name.dangerous_unsafe_unsanitized_raw().as_str()) ) => {} Some(raw_file_name) => err!( "Send file name does not match.", format!( "Expected file name '{}' got '{}'", send_data.fileName, raw_file_name.dangerous_unsafe_unsanitized_raw() ) ), _ => err!("Send file name does not match or is not provided."), } if file_id != send_data.id { err!("Send file does not match send data.", format!("Expected id {} got {file_id}", send_data.id)); } let Some(size) = data.data.len().to_u64() else { err!("Send file size overflow."); }; if size != send_data.size { err!("Send file size does not match.", format!("Expected a file size of {} got {size}", send_data.size)); } let file_path = format!("{send_id}/{file_id}"); save_temp_file(&PathType::Sends, &file_path, data.data, false).await?; nt.send_send_update( UpdateType::SyncSendCreate, &send, &send.update_users_revision(&conn).await, &headers.device, &conn, ) .await; Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendAccessData { pub password: Option, } #[post("/sends/access/", data = "")] async fn post_access( access_id: &str, data: Json, conn: DbConn, ip: ClientIp, nt: Notify<'_>, ) -> JsonResult { let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; if let Some(max_access_count) = send.max_access_count { if send.access_count >= max_access_count { err_code!(SEND_INACCESSIBLE_MSG, 404); } } if let Some(expiration) = send.expiration_date { if Utc::now().naive_utc() >= expiration { err_code!(SEND_INACCESSIBLE_MSG, 404) } } if Utc::now().naive_utc() >= send.deletion_date { err_code!(SEND_INACCESSIBLE_MSG, 404) } if send.disabled { err_code!(SEND_INACCESSIBLE_MSG, 404) } if send.password_hash.is_some() { match data.into_inner().password { Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)), None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401), } } // Files are incremented during the download if send.atype == SendType::Text as i32 { send.access_count += 1; } send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&conn).await, &ANON_PUSH_DEVICE, &conn, ) .await; Ok(Json(send.to_json_access(&conn).await)) } #[post("/sends//access/file/", data = "")] async fn post_access_file( send_id: SendId, file_id: SendFileId, data: Json, host: Host, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { err_code!(SEND_INACCESSIBLE_MSG, 404) }; if let Some(max_access_count) = send.max_access_count { if send.access_count >= max_access_count { err_code!(SEND_INACCESSIBLE_MSG, 404) } } if let Some(expiration) = send.expiration_date { if Utc::now().naive_utc() >= expiration { err_code!(SEND_INACCESSIBLE_MSG, 404) } } if Utc::now().naive_utc() >= send.deletion_date { err_code!(SEND_INACCESSIBLE_MSG, 404) } if send.disabled { err_code!(SEND_INACCESSIBLE_MSG, 404) } if send.password_hash.is_some() { match data.into_inner().password { Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } Some(_) => err!("Invalid password."), None => err_code!("Password not provided", 401), } } send.access_count += 1; send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&conn).await, &ANON_PUSH_DEVICE, &conn, ) .await; Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, "url": download_url(&host, &send_id, &file_id).await?, }))) } async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token = crate::auth::encode_jwt(&token_claims); Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host)) } else { Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_secs(5 * 60)).await?.uri().to_string()) } } #[get("/sends//?")] async fn download_send(send_id: SendId, file_id: SendFileId, t: &str) -> Option { if let Ok(claims) = crate::auth::decode_send(t) { if claims.sub == format!("{send_id}/{file_id}") { return NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).await.ok(); } } None } #[put("/sends/", data = "")] async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Send send_id is invalid or does not belong to user") }; update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) } pub async fn update_send_from_data( send: &mut Send, data: SendData, headers: &Headers, conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { if send.user_uuid.as_ref() != Some(&headers.user.uuid) { err!("Send is not owned by user") } if send.atype != data.r#type { err!("Sends can't change type") } if data.deletion_date > Utc::now() + TimeDelta::try_days(31).unwrap() { err!( "You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again." ); } // When updating a file Send, we receive nulls in the File field, as it's immutable, // so we only need to update the data field in the Text case if data.r#type == SendType::Text as i32 { let data_str = if let Some(mut d) = data.text { d.as_object_mut().and_then(|d| d.remove("response")); serde_json::to_string(&d)? } else { err!("Send data not provided"); }; send.data = data_str; } send.name = data.name; send.akey = data.key; send.deletion_date = data.deletion_date.naive_utc(); send.notes = data.notes; send.max_access_count = match data.max_access_count { Some(m) => Some(m.into_i32()?), _ => None, }; send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.hide_email = data.hide_email; send.disabled = data.disabled; // Only change the value if it's present if let Some(password) = data.password { send.set_password(Some(&password)); } send.save(conn).await?; if ut != UpdateType::None { nt.send_send_update(ut, send, &send.update_users_revision(conn).await, &headers.device, conn).await; } Ok(()) } #[delete("/sends/")] async fn delete_send(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Invalid send uuid, or does not belong to user") }; send.delete(&conn).await?; nt.send_send_update( UpdateType::SyncSendDelete, &send, &send.update_users_revision(&conn).await, &headers.device, &conn, ) .await; Ok(()) } #[put("/sends//remove-password")] async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { enforce_disable_send_policy(&headers, &conn).await?; let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Invalid send uuid, or does not belong to user") }; send.set_password(None); send.save(&conn).await?; nt.send_send_update( UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&conn).await, &headers.device, &conn, ) .await; Ok(Json(send.to_json())) } ================================================ FILE: src/api/core/two_factor/authenticator.rs ================================================ use data_encoding::BASE32; use rocket::serde::json::Json; use rocket::Route; use crate::{ api::{core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, PasswordOrOtpData}, auth::{ClientIp, Headers}, crypto, db::{ models::{EventType, TwoFactor, TwoFactorType, UserId}, DbConn, }, util::NumberOrString, }; pub use crate::config::CONFIG; pub fn routes() -> Vec { routes![generate_authenticator, activate_authenticator, activate_authenticator_put, disable_authenticator] } #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let type_ = TwoFactorType::Authenticator as i32; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), }; // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. // It should help prevent TOTP disclosure if someone keeps their vault unlocked. // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ "enabled": enabled, "key": key, "object": "twoFactorAuthenticator" }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableAuthenticatorData { key: String, token: NumberOrString, master_password_hash: Option, otp: Option, } #[post("/two-factor/authenticator", data = "")] async fn activate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableAuthenticatorData = data.into_inner(); let key = data.key; let token = data.token.into_string(); let mut user = headers.user; PasswordOrOtpData { master_password_hash: data.master_password_hash, otp: data.otp, } .validate(&user, true, &conn) .await?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = match BASE32.decode(key.as_bytes()) { Ok(decoded) => decoded, _ => err!("Invalid totp secret"), }; if decoded_key.len() != 20 { err!("Invalid key length") } // Validate the token provided with the key, and save new twofactor validate_totp_code(&user.uuid, &token, &key.to_uppercase(), &headers.ip, &conn).await?; _generate_recover_code(&mut user, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ "enabled": true, "key": key, "object": "twoFactorAuthenticator" }))) } #[put("/two-factor/authenticator", data = "")] async fn activate_authenticator_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { activate_authenticator(data, headers, conn).await } pub async fn validate_totp_code_str( user_id: &UserId, totp_code: &str, secret: &str, ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { if !totp_code.chars().all(char::is_numeric) { err!("TOTP code is not a number"); } validate_totp_code(user_id, totp_code, secret, ip, conn).await } pub async fn validate_totp_code( user_id: &UserId, totp_code: &str, secret: &str, ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { use totp_lite::{totp_custom, Sha1}; let Ok(decoded_secret) = BASE32.decode(secret.as_bytes()) else { err!("Invalid TOTP secret") }; let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await { Some(tf) => tf, _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_string()), }; // The amount of steps back and forward in time // Also check if we need to disable time drifted TOTP codes. // If that is the case, we set the steps to 0 so only the current TOTP is valid. let steps = i64::from(!CONFIG.authenticator_disable_time_drift()); // Get the current system time in UNIX Epoch (UTC) let current_time = chrono::Utc::now(); let current_timestamp = current_time.timestamp(); for step in -steps..=steps { let time_step = current_timestamp / 30i64 + step; // We need to calculate the time offsite and cast it as an u64. // Since we only have times into the future and the totp generator needs an u64 instead of the default i64. let time = (current_timestamp + step * 30i64) as u64; let generated = totp_custom::(30, 6, &decoded_secret, time); // Check the given code equals the generated and if the time_step is larger then the one last used. if generated == totp_code && time_step > twofactor.last_used { // If the step does not equals 0 the time is drifted either server or client side. if step != 0 { warn!("TOTP Time drift detected. The step offset is {step}"); } // Save the last used time step so only totp time steps higher then this one are allowed. // This will also save a newly created twofactor if the code is correct. twofactor.last_used = time_step; twofactor.save(conn).await?; return Ok(()); } else if generated == totp_code && time_step <= twofactor.last_used { warn!("This TOTP or a TOTP code within {steps} steps back or forward has already been used!"); err!( format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), ErrorEvent { event: EventType::UserFailedLogIn2fa } ); } } // Else no valid code received, deny access err!( format!("Invalid TOTP code! Server time: {} IP: {}", current_time.format("%F %T UTC"), ip.ip), ErrorEvent { event: EventType::UserFailedLogIn2fa } ); } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, master_password_hash: String, r#type: NumberOrString, } #[delete("/two-factor/authenticator", data = "")] async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; let type_ = data.r#type.into_i32()?; if !user.check_valid_password(&data.master_password_hash) { err!("Invalid password"); } if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; } else { err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); } } if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; } Ok(Json(json!({ "enabled": false, "keys": type_, "object": "twoFactorProvider" }))) } ================================================ FILE: src/api/core/two_factor/duo.rs ================================================ use chrono::Utc; use data_encoding::BASE64; use rocket::serde::json::Json; use rocket::Route; use crate::{ api::{ core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::Headers, crypto, db::{ models::{EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, }, error::MapResult, http_client::make_http_request, CONFIG, }; pub fn routes() -> Vec { routes![get_duo, activate_duo, activate_duo_put,] } #[derive(Serialize, Deserialize)] struct DuoData { host: String, // Duo API hostname ik: String, // client id sk: String, // client secret } impl DuoData { fn global() -> Option { match (CONFIG._enable_duo(), CONFIG.duo_host()) { (true, Some(host)) => Some(Self { host, ik: CONFIG.duo_ikey().unwrap(), sk: CONFIG.duo_skey().unwrap(), }), _ => None, } } fn msg(s: &str) -> Self { Self { host: s.into(), ik: s.into(), sk: s.into(), } } fn secret() -> Self { Self::msg("") } fn obscure(self) -> Self { let mut host = self.host; let mut ik = self.ik; let mut sk = self.sk; let digits = 4; let replaced = "************"; host.replace_range(digits.., replaced); ik.replace_range(digits.., replaced); sk.replace_range(digits.., replaced); Self { host, ik, sk, } } } enum DuoStatus { Global(DuoData), // Using the global duo config User(DuoData), // Using the user's config Disabled(bool), // True if there is a global setting } impl DuoStatus { fn data(self) -> Option { match self { DuoStatus::Global(data) => Some(data), DuoStatus::User(data) => Some(data), DuoStatus::Disabled(_) => None, } } } const DISABLED_MESSAGE_DEFAULT: &str = ""; #[post("/two-factor/get-duo", data = "")] async fn get_duo(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let data = get_user_duo_data(&user.uuid, &conn).await; let (enabled, data) = match data { DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(false) => (false, None), }; let json = if let Some(data) = data { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, "object": "twoFactorDuo" }) } else { json!({ "enabled": enabled, "host": null, "clientSecret": null, "clientId": null, "object": "twoFactorDuo" }) }; Ok(Json(json)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EnableDuoData { host: String, client_secret: String, client_id: String, master_password_hash: Option, otp: Option, } impl From for DuoData { fn from(d: EnableDuoData) -> Self { Self { host: d.host, ik: d.client_id, sk: d.client_secret, } } } fn check_duo_fields_custom(data: &EnableDuoData) -> bool { fn empty_or_default(s: &str) -> bool { let st = s.trim(); st.is_empty() || s == DISABLED_MESSAGE_DEFAULT } !empty_or_default(&data.host) && !empty_or_default(&data.client_secret) && !empty_or_default(&data.client_id) } #[post("/two-factor/duo", data = "")] async fn activate_duo(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableDuoData = data.into_inner(); let mut user = headers.user; PasswordOrOtpData { master_password_hash: data.master_password_hash.clone(), otp: data.otp.clone(), } .validate(&user, true, &conn) .await?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); let data_str = serde_json::to_string(&data_req)?; duo_api_request("GET", "/auth/v2/check", "", &data_req).await.map_res("Failed to validate Duo credentials")?; (data_req.obscure(), data_str) } else { (DuoData::secret(), String::new()) }; let type_ = TwoFactorType::Duo; let twofactor = TwoFactor::new(user.uuid.clone(), type_, data_str); twofactor.save(&conn).await?; _generate_recover_code(&mut user, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ "enabled": true, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, "object": "twoFactorDuo" }))) } #[put("/two-factor/duo", data = "")] async fn activate_duo_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { activate_duo(data, headers, conn).await } async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { use reqwest::{header, Method}; use std::str::FromStr; // https://duo.com/docs/authapi#api-details let url = format!("https://{}{path}", &data.host); let date = Utc::now().to_rfc2822(); let username = &data.ik; let fields = [&date, method, &data.host, path, params]; let password = crypto::hmac_sign(&data.sk, &fields.join("\n")); let m = Method::from_str(method).unwrap_or_default(); make_http_request(m, &url)? .basic_auth(username, Some(password)) .header(header::USER_AGENT, "vaultwarden:Duo/1.0 (Rust)") .header(header::DATE, date) .send() .await? .error_for_status()?; Ok(()) } const DUO_EXPIRE: i64 = 300; const APP_EXPIRE: i64 = 3600; const AUTH_PREFIX: &str = "AUTH"; const DUO_PREFIX: &str = "TX"; const APP_PREFIX: &str = "APP"; async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { let type_ = TwoFactorType::Duo as i32; // If the user doesn't have an entry, disabled let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { return DuoStatus::Disabled(DuoData::global().is_some()); }; // If the user has the required values, we use those if let Ok(data) = serde_json::from_str(&twofactor.data) { return DuoStatus::User(data); } // Otherwise, we try to use the globals if let Some(global) = DuoData::global() { return DuoStatus::Global(global); } // If there are no globals configured, just disable it DuoStatus::Disabled(false) } // let (ik, sk, ak, host) = get_duo_keys(); pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> { let data = match User::find_by_mail(email, conn).await { Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), _ => DuoData::global(), } .map_res("Can't fetch Duo Keys")?; Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host)) } pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> { let now = Utc::now().timestamp(); let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?; let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE); let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE); Ok((format!("{duo_sign}:{app_sign}"), host)) } fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String { let val = format!("{email}|{ikey}|{expire}"); let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes())); format!("{cookie}|{}", crypto::hmac_sign(key, &cookie)) } pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult { let split: Vec<&str> = response.split(':').collect(); if split.len() != 2 { err!( "Invalid response length", ErrorEvent { event: EventType::UserFailedLogIn2fa } ); } let auth_sig = split[0]; let app_sig = split[1]; let now = Utc::now().timestamp(); let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?; let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?; let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?; if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) { err!( "Error validating duo authentication", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } Ok(()) } fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult { let split: Vec<&str> = val.split('|').collect(); if split.len() != 3 { err!("Invalid value length") } let u_prefix = split[0]; let u_b64 = split[1]; let u_sig = split[2]; let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}")); if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) { err!("Duo signatures don't match") } if u_prefix != prefix { err!("Prefixes don't match") } let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else { err!("Invalid Duo cookie encoding") }; let Ok(cookie) = String::from_utf8(cookie_vec) else { err!("Invalid Duo cookie encoding") }; let cookie_split: Vec<&str> = cookie.split('|').collect(); if cookie_split.len() != 3 { err!("Invalid cookie length") } let username = cookie_split[0]; let u_ikey = cookie_split[1]; let expire = cookie_split[2]; if !crypto::ct_eq(ikey, u_ikey) { err!("Invalid ikey") } let expire: i64 = match expire.parse() { Ok(e) => e, Err(_) => err!("Invalid expire time"), }; if time >= expire { err!("Expired authorization") } Ok(username.into()) } ================================================ FILE: src/api/core/two_factor/duo_oidc.rs ================================================ use chrono::Utc; use data_encoding::HEXLOWER; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use reqwest::{header, StatusCode}; use ring::digest::{digest, Digest, SHA512_256}; use serde::Serialize; use std::collections::HashMap; use crate::{ api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, crypto, db::{ models::{DeviceId, EventType, TwoFactorDuoContext}, DbConn, DbPool, }, error::Error, http_client::make_http_request, CONFIG, }; use url::Url; // The location on this service that Duo should redirect users to. For us, this is a bridge // built in to the Bitwarden clients. // See: https://github.com/bitwarden/clients/blob/5fb46df3415aefced0b52f2db86c873962255448/apps/web/src/connectors/duo-redirect.ts const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; // Number of seconds that a JWT we generate for Duo should be valid for. const JWT_VALIDITY_SECS: i64 = 300; // Number of seconds that a Duo context stored in the database should be valid for. const CTX_VALIDITY_SECS: i64 = 300; // Expected algorithm used by Duo to sign JWTs. const DUO_RESP_SIGNATURE_ALG: Algorithm = Algorithm::HS512; // Signature algorithm we're using to sign JWTs for Duo. Must be either HS512 or HS256. const JWT_SIGNATURE_ALG: Algorithm = Algorithm::HS512; // Size of random strings for state and nonce. Must be at least 16 characters and at most 1024 characters. // If increasing this above 64, also increase the size of the twofactor_duo_ctx.state and // twofactor_duo_ctx.nonce database columns for postgres and mariadb. const STATE_LENGTH: usize = 64; // client_assertion payload for health checks and obtaining MFA results. #[derive(Debug, Serialize, Deserialize)] struct ClientAssertion { pub iss: String, pub sub: String, pub aud: String, pub exp: i64, pub jti: String, pub iat: i64, } // authorization request payload sent with clients to Duo for MFA #[derive(Debug, Serialize, Deserialize)] struct AuthorizationRequest { pub response_type: String, pub scope: String, pub exp: i64, pub client_id: String, pub redirect_uri: String, pub state: String, pub duo_uname: String, pub iss: String, pub aud: String, pub nonce: String, } // Duo service health check responses #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] enum HealthCheckResponse { HealthOK { stat: String, }, HealthFail { message: String, message_detail: String, }, } // Outer structure of response when exchanging authz code for MFA results #[derive(Debug, Serialize, Deserialize)] struct IdTokenResponse { id_token: String, // IdTokenClaims access_token: String, expires_in: i64, token_type: String, } // Inner structure of IdTokenResponse.id_token #[derive(Debug, Serialize, Deserialize)] struct IdTokenClaims { preferred_username: String, nonce: String, } // Duo OIDC Authorization Client // See https://duo.com/docs/oauthapi struct DuoClient { client_id: String, // Duo Client ID (DuoData.ik) client_secret: String, // Duo Client Secret (DuoData.sk) api_host: String, // Duo API hostname (DuoData.host) redirect_uri: String, // URL in this application clients should call for MFA verification } impl DuoClient { // Construct a new DuoClient fn new(client_id: String, client_secret: String, api_host: String, redirect_uri: String) -> DuoClient { DuoClient { client_id, client_secret, api_host, redirect_uri, } } // Generate a client assertion for health checks and authorization code exchange. fn new_client_assertion(&self, url: &str) -> ClientAssertion { let now = Utc::now().timestamp(); let jwt_id = crypto::get_random_string_alphanum(STATE_LENGTH); ClientAssertion { iss: self.client_id.clone(), sub: self.client_id.clone(), aud: url.to_string(), exp: now + JWT_VALIDITY_SECS, jti: jwt_id, iat: now, } } // Given a serde-serializable struct, attempt to encode it as a JWT fn encode_duo_jwt(&self, jwt_payload: T) -> Result { match jsonwebtoken::encode( &Header::new(JWT_SIGNATURE_ALG), &jwt_payload, &EncodingKey::from_secret(self.client_secret.as_bytes()), ) { Ok(token) => Ok(token), Err(e) => err!(format!("Error encoding Duo JWT: {e:?}")), } } // "required" health check to verify the integration is configured and Duo's services // are up. // https://duo.com/docs/oauthapi#health-check async fn health_check(&self) -> Result<(), Error> { let health_check_url: String = format!("https://{}/oauth/v1/health_check", self.api_host); let jwt_payload = self.new_client_assertion(&health_check_url); let token = match self.encode_duo_jwt(jwt_payload) { Ok(token) => token, Err(e) => return Err(e), }; let mut post_body = HashMap::new(); post_body.insert("client_assertion", token); post_body.insert("client_id", self.client_id.clone()); let res = match make_http_request(reqwest::Method::POST, &health_check_url)? .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") .form(&post_body) .send() .await { Ok(r) => r, Err(e) => err!(format!("Error requesting Duo health check: {e:?}")), }; let response: HealthCheckResponse = match res.json::().await { Ok(r) => r, Err(e) => err!(format!("Duo health check response decode error: {e:?}")), }; let health_stat: String = match response { HealthCheckResponse::HealthOK { stat, } => stat, HealthCheckResponse::HealthFail { message, message_detail, } => err!(format!("Duo health check FAIL response, msg: {message}, detail: {message_detail}")), }; if health_stat != "OK" { err!(format!("Duo health check failed, got OK-like body with stat {health_stat}")); } Ok(()) } // Constructs the URL for the authorization request endpoint on Duo's service. // Clients are sent here to continue authentication. // https://duo.com/docs/oauthapi#authorization-request fn make_authz_req_url(&self, duo_username: &str, state: String, nonce: String) -> Result { let now = Utc::now().timestamp(); let jwt_payload = AuthorizationRequest { response_type: String::from("code"), scope: String::from("openid"), exp: now + JWT_VALIDITY_SECS, client_id: self.client_id.clone(), redirect_uri: self.redirect_uri.clone(), state, duo_uname: String::from(duo_username), iss: self.client_id.clone(), aud: format!("https://{}", self.api_host), nonce, }; let token = self.encode_duo_jwt(jwt_payload)?; let authz_endpoint = format!("https://{}/oauth/v1/authorize", self.api_host); let mut auth_url = match Url::parse(authz_endpoint.as_str()) { Ok(url) => url, Err(e) => err!(format!("Error parsing Duo authorization URL: {e:?}")), }; { let mut query_params = auth_url.query_pairs_mut(); query_params.append_pair("response_type", "code"); query_params.append_pair("client_id", self.client_id.as_str()); query_params.append_pair("request", token.as_str()); } let final_auth_url = auth_url.to_string(); Ok(final_auth_url) } // Exchange the authorization code obtained from an access token provided by the user // for the result of the MFA and validate. // See: https://duo.com/docs/oauthapi#access-token (under Response Format) async fn exchange_authz_code_for_result( &self, duo_code: &str, duo_username: &str, nonce: &str, ) -> Result<(), Error> { if duo_code.is_empty() { err!("Empty Duo authorization code") } let token_url = format!("https://{}/oauth/v1/token", self.api_host); let jwt_payload = self.new_client_assertion(&token_url); let token = match self.encode_duo_jwt(jwt_payload) { Ok(token) => token, Err(e) => return Err(e), }; let mut post_body = HashMap::new(); post_body.insert("grant_type", String::from("authorization_code")); post_body.insert("code", String::from(duo_code)); // Must be the same URL that was supplied in the authorization request for the supplied duo_code post_body.insert("redirect_uri", self.redirect_uri.clone()); post_body .insert("client_assertion_type", String::from("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); post_body.insert("client_assertion", token); let res = match make_http_request(reqwest::Method::POST, &token_url)? .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") .form(&post_body) .send() .await { Ok(r) => r, Err(e) => err!(format!("Error exchanging Duo code: {e:?}")), }; let status_code = res.status(); if status_code != StatusCode::OK { err!(format!("Failure response from Duo: {status_code}")) } let response: IdTokenResponse = match res.json::().await { Ok(r) => r, Err(e) => err!(format!("Error decoding ID token response: {e:?}")), }; let mut validation = Validation::new(DUO_RESP_SIGNATURE_ALG); validation.set_required_spec_claims(&["exp", "aud", "iss"]); validation.set_audience(&[&self.client_id]); validation.set_issuer(&[token_url.as_str()]); let token_data = match jsonwebtoken::decode::( &response.id_token, &DecodingKey::from_secret(self.client_secret.as_bytes()), &validation, ) { Ok(c) => c, Err(e) => err!(format!("Failed to decode Duo token {e:?}")), }; let matching_nonces = crypto::ct_eq(nonce, &token_data.claims.nonce); let matching_usernames = crypto::ct_eq(duo_username, &token_data.claims.preferred_username); if !(matching_nonces && matching_usernames) { err!("Error validating Duo authorization, nonce or username mismatch.") }; Ok(()) } } struct DuoAuthContext { pub state: String, pub user_email: String, pub nonce: String, pub exp: i64, } // Given a state string, retrieve the associated Duo auth context and // delete the retrieved state from the database. async fn extract_context(state: &str, conn: &DbConn) -> Option { let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await { Some(c) => c, None => return None, }; if ctx.exp < Utc::now().timestamp() { ctx.delete(conn).await.ok(); return None; } // Copy the context data, so that we can delete the context from // the database before returning. let ret_ctx = DuoAuthContext { state: ctx.state.clone(), user_email: ctx.user_email.clone(), nonce: ctx.nonce.clone(), exp: ctx.exp, }; ctx.delete(conn).await.ok(); Some(ret_ctx) } // Task to clean up expired Duo authentication contexts that may have accumulated in the database. pub async fn purge_duo_contexts(pool: DbPool) { debug!("Purging Duo authentication contexts"); if let Ok(conn) = pool.get().await { TwoFactorDuoContext::purge_expired_duo_contexts(&conn).await; } else { error!("Failed to get DB connection while purging expired Duo authentications") } } // Construct the url that Duo should redirect users to. fn make_callback_url(client_name: &str) -> Result { // Get the location of this application as defined in the config. let base = match Url::parse(&format!("{}/", CONFIG.domain())) { Ok(url) => url, Err(e) => err!(format!("Error parsing configured domain URL (check your domain configuration): {e:?}")), }; // Add the client redirect bridge location let mut callback = match base.join(DUO_REDIRECT_LOCATION) { Ok(url) => url, Err(e) => err!(format!("Error constructing Duo redirect URL (check your domain configuration): {e:?}")), }; // Add the 'client' string with the authenticating device type. The callback connector uses this // information to figure out how it should handle certain clients. { let mut query_params = callback.query_pairs_mut(); query_params.append_pair("client", client_name); } Ok(callback.to_string()) } // Pre-redirect first stage of the Duo OIDC authentication flow. // Returns the "AuthUrl" that should be returned to clients for MFA. pub async fn get_duo_auth_url( email: &str, client_id: &str, device_identifier: &DeviceId, conn: &DbConn, ) -> Result { let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; let callback_url = match make_callback_url(client_id) { Ok(url) => url, Err(e) => return Err(e), }; let client = DuoClient::new(ik, sk, host, callback_url); match client.health_check().await { Ok(()) => {} Err(e) => return Err(e), }; // Generate random OAuth2 state and OIDC Nonce let state: String = crypto::get_random_string_alphanum(STATE_LENGTH); let nonce: String = crypto::get_random_string_alphanum(STATE_LENGTH); // Bind the nonce to the device that's currently authing by hashing the nonce and device id // and sending the result as the OIDC nonce. let d: Digest = digest(&SHA512_256, format!("{nonce}{device_identifier}").as_bytes()); let hash: String = HEXLOWER.encode(d.as_ref()); match TwoFactorDuoContext::save(state.as_str(), email, nonce.as_str(), CTX_VALIDITY_SECS, conn).await { Ok(()) => client.make_authz_req_url(email, state, hash), Err(e) => err!(format!("Error saving Duo authentication context: {e:?}")), } } // Post-redirect second stage of the Duo OIDC authentication flow. // Exchanges an authorization code for the MFA result with Duo's API and validates the result. pub async fn validate_duo_login( email: &str, two_factor_token: &str, client_id: &str, device_identifier: &DeviceId, conn: &DbConn, ) -> EmptyResult { // Result supplied to us by clients in the form "|" let split: Vec<&str> = two_factor_token.split('|').collect(); if split.len() != 2 { err!( "Invalid response length", ErrorEvent { event: EventType::UserFailedLogIn2fa } ); } let code = split[0]; let state = split[1]; let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; // Get the context by the state reported by the client. If we don't have one, // it means the context is either missing or expired. let ctx = match extract_context(state, conn).await { Some(c) => c, None => { err!( "Error validating duo authentication", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } }; // Context validation steps let matching_usernames = crypto::ct_eq(email, &ctx.user_email); // Probably redundant, but we're double-checking them anyway. let matching_states = crypto::ct_eq(state, &ctx.state); let unexpired_context = ctx.exp > Utc::now().timestamp(); if !(matching_usernames && matching_states && unexpired_context) { err!( "Error validating duo authentication", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } let callback_url = match make_callback_url(client_id) { Ok(url) => url, Err(e) => return Err(e), }; let client = DuoClient::new(ik, sk, host, callback_url); match client.health_check().await { Ok(()) => {} Err(e) => return Err(e), }; let d: Digest = digest(&SHA512_256, format!("{}{device_identifier}", ctx.nonce).as_bytes()); let hash: String = HEXLOWER.encode(d.as_ref()); match client.exchange_authz_code_for_result(code, email, hash.as_str()).await { Ok(_) => Ok(()), Err(_) => { err!( "Error validating duo authentication", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } } } ================================================ FILE: src/api/core/two_factor/email.rs ================================================ use chrono::{DateTime, TimeDelta, Utc}; use rocket::serde::json::Json; use rocket::Route; use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::{ClientHeaders, Headers}, crypto, db::{ models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, DbConn, }, error::{Error, MapResult}, mail, CONFIG, }; pub fn routes() -> Vec { routes![get_email, send_email_login, send_email, email,] } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailLoginData { #[serde(alias = "DeviceIdentifier")] device_identifier: DeviceId, #[serde(alias = "Email")] email: Option, #[serde(alias = "MasterPasswordHash")] master_password_hash: Option, auth_request_id: Option, auth_request_access_code: Option, } /// User is trying to login and wants to use email 2FA. /// Does not require Bearer token #[post("/two-factor/send-email-login", data = "")] // JsonResult async fn send_email_login(data: Json, client_headers: ClientHeaders, conn: DbConn) -> EmptyResult { let data: SendEmailLoginData = data.into_inner(); if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } // Ratelimit the login crate::ratelimit::check_limit_login(&client_headers.ip.ip)?; // Get the user let email = match &data.email { Some(email) if !email.is_empty() => Some(email), _ => None, }; let master_password_hash = match &data.master_password_hash { Some(password_hash) if !password_hash.is_empty() => Some(password_hash), _ => None, }; let auth_request_id = match &data.auth_request_id { Some(auth_request_id) if !auth_request_id.is_empty() => Some(auth_request_id), _ => None, }; let user = if let Some(email) = email { let Some(user) = User::find_by_mail(email, &conn).await else { err!("Username or password is incorrect. Try again.") }; if let Some(master_password_hash) = master_password_hash { // Check password if !user.check_valid_password(master_password_hash) { err!("Username or password is incorrect. Try again.") } } else if let Some(auth_request_id) = auth_request_id { let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { err!("AuthRequest doesn't exist", "User not found") }; let Some(code) = &data.auth_request_access_code else { err!("no auth request access code") }; if auth_request.device_type != client_headers.device_type || auth_request.request_ip != client_headers.ip.ip.to_string() || !auth_request.check_access_code(code) { err!("AuthRequest doesn't exist", "Invalid device, IP or code") } } else { err!("No password hash has been submitted.") } user } else { // SSO login only sends device id, so we get the user by the most recently used device let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { err!("Username or password is incorrect. Try again.") }; user }; send_token(&user.uuid, &conn).await } /// Generate the token, save the data for later verification and send email to user pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { let type_ = TwoFactorType::Email as i32; let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); let mut twofactor_data = EmailTokenData::from_json(&twofactor.data)?; twofactor_data.set_token(generated_token); twofactor.data = twofactor_data.to_json(); twofactor.save(conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; Ok(()) } /// When user clicks on Manage email 2FA show the user the related information #[post("/two-factor/get-email", data = "")] async fn get_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { Some(x) => { let twofactor_data = EmailTokenData::from_json(&x.data)?; (true, json!(twofactor_data.email)) } _ => (false, serde_json::value::Value::Null), }; Ok(Json(json!({ "email": mfa_email, "enabled": enabled, "object": "twoFactorEmail" }))) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SendEmailData { /// Email where 2FA codes will be sent to, can be different than user email account. email: String, master_password_hash: Option, otp: Option, } /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: SendEmailData = data.into_inner(); let user = headers.user; PasswordOrOtpData { master_password_hash: data.master_password_hash, otp: data.otp, } .validate(&user, false, &conn) .await?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } let type_ = TwoFactorType::Email as i32; if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { tf.delete(&conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); let twofactor_data = EmailTokenData::new(data.email, generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); twofactor.save(&conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; Ok(()) } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct EmailData { email: String, token: String, master_password_hash: Option, otp: Option, } /// Verify email belongs to user and can be used for 2FA email codes. #[put("/two-factor/email", data = "")] async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EmailData = data.into_inner(); let mut user = headers.user; // This is the last step in the verification process, delete the otp directly afterwards PasswordOrOtpData { master_password_hash: data.master_password_hash, otp: data.otp, } .validate(&user, true, &conn) .await?; let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; let Some(issued_token) = &email_data.last_token else { err!("No token available") }; if !crypto::ct_eq(issued_token, data.token) { err!("Token is invalid") } email_data.reset_token(); twofactor.atype = TwoFactorType::Email as i32; twofactor.data = email_data.to_json(); twofactor.save(&conn).await?; _generate_recover_code(&mut user, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ "email": email_data.email, "enabled": "true", "object": "twoFactorEmail" }))) } /// Validate the email code when used as TwoFactor token mechanism pub async fn validate_email_code_str( user_id: &UserId, token: &str, data: &str, ip: &std::net::IpAddr, conn: &DbConn, ) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) .await .map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), ErrorEvent { event: EventType::UserFailedLogIn2fa } ) }; if !crypto::ct_eq(issued_token, token) { email_data.add_attempt(); if email_data.attempts >= CONFIG.email_attempts_limit() { email_data.reset_token(); } twofactor.data = email_data.to_json(); twofactor.save(conn).await?; err!( format!("Token is invalid! IP: {ip}"), ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } email_data.reset_token(); twofactor.data = email_data.to_json(); twofactor.save(conn).await?; let date = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); let max_time = CONFIG.email_expiration_time() as i64; if date + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { err!( "Token has expired", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } Ok(()) } /// Data stored in the TwoFactor table in the db #[derive(Serialize, Deserialize)] pub struct EmailTokenData { /// Email address where the token will be sent to. Can be different from account email. pub email: String, /// Some(token): last valid token issued that has not been entered. /// None: valid token was used and removed. pub last_token: Option, /// UNIX timestamp of token issue. pub token_sent: i64, /// Amount of token entry attempts for last_token. pub attempts: u64, } impl EmailTokenData { pub fn new(email: String, token: String) -> EmailTokenData { EmailTokenData { email, last_token: Some(token), token_sent: Utc::now().timestamp(), attempts: 0, } } pub fn set_token(&mut self, token: String) { self.last_token = Some(token); self.token_sent = Utc::now().timestamp(); } pub fn reset_token(&mut self) { self.last_token = None; self.attempts = 0; } pub fn add_attempt(&mut self) { self.attempts = self.attempts.saturating_add(1); } pub fn to_json(&self) -> String { serde_json::to_string(&self).unwrap() } pub fn from_json(string: &str) -> Result { let res: Result = serde_json::from_str(string); match res { Ok(x) => Ok(x), Err(_) => err!("Could not decode EmailTokenData from string"), } } } pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult { if user.verified_at.is_none() { err!("Auto-enabling of email 2FA failed because the users email address has not been verified!"); } let twofactor_data = EmailTokenData::new(user.email.clone(), String::new()); let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json()); twofactor.save(conn).await } /// Takes an email address and obscures it by replacing it with asterisks except two characters. pub fn obscure_email(email: &str) -> String { let split: Vec<&str> = email.rsplitn(2, '@').collect(); let mut name = split[1].to_string(); let domain = &split[0]; let name_size = name.chars().count(); let new_name = match name_size { 1..=3 => "*".repeat(name_size), _ => { let stars = "*".repeat(name_size - 2); name.truncate(2); format!("{name}{stars}") } }; format!("{new_name}@{domain}") } pub async fn find_and_activate_email_2fa(user_id: &UserId, conn: &DbConn) -> EmptyResult { if let Some(user) = User::find_by_uuid(user_id, conn).await { activate_email_2fa(&user, conn).await } else { err!("User not found!"); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_obscure_email_long() { let email = "bytes@example.ext"; let result = obscure_email(email); // Only first two characters should be visible. assert_eq!(result, "by***@example.ext"); } #[test] fn test_obscure_email_short() { let email = "byt@example.ext"; let result = obscure_email(email); // If it's smaller than 3 characters it should only show asterisks. assert_eq!(result, "***@example.ext"); } } ================================================ FILE: src/api/core/two_factor/mod.rs ================================================ use chrono::{TimeDelta, Utc}; use data_encoding::BASE32; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; use crate::{ api::{ core::{log_event, log_user_event}, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::Headers, crypto, db::{ models::{ DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, TwoFactorIncomplete, User, UserId, }, DbConn, DbPool, }, mail, util::NumberOrString, CONFIG, }; pub mod authenticator; pub mod duo; pub mod duo_oidc; pub mod email; pub mod protected_actions; pub mod webauthn; pub mod yubikey; pub fn routes() -> Vec { let mut routes = routes![ get_twofactor, get_recover, disable_twofactor, disable_twofactor_put, get_device_verification_settings, ]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); routes.append(&mut email::routes()); routes.append(&mut webauthn::routes()); routes.append(&mut yubikey::routes()); routes.append(&mut protected_actions::routes()); routes } #[get("/two-factor")] async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; let twofactors_json: Vec = twofactors.iter().map(TwoFactor::to_json_provider).collect(); Json(json!({ "data": twofactors_json, "object": "list", "continuationToken": null, })) } #[post("/two-factor/get-recover", data = "")] async fn get_recover(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, true, &conn).await?; Ok(Json(json!({ "code": user.totp_recover, "object": "twoFactorRecover" }))) } async fn _generate_recover_code(user: &mut User, conn: &DbConn) { if user.totp_recover.is_none() { let totp_recover = crypto::encode_random_bytes::<20>(&BASE32); user.totp_recover = Some(totp_recover); user.save(conn).await.ok(); } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DisableTwoFactorData { master_password_hash: Option, otp: Option, r#type: NumberOrString, } #[post("/two-factor/disable", data = "")] async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: DisableTwoFactorData = data.into_inner(); let user = headers.user; // Delete directly after a valid token has been provided PasswordOrOtpData { master_password_hash: data.master_password_hash, otp: data.otp, } .validate(&user, true, &conn) .await?; let type_ = data.r#type.into_i32()?; if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { twofactor.delete(&conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; } if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; } Ok(Json(json!({ "enabled": false, "type": type_, "object": "twoFactorProvider" }))) } #[put("/two-factor/disable", data = "")] async fn disable_twofactor_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { disable_twofactor(data, headers, conn).await } pub async fn enforce_2fa_policy( user: &User, act_user_id: &UserId, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn, ) -> EmptyResult { for member in Membership::find_by_user_and_policy(&user.uuid, OrgPolicyType::TwoFactorAuthentication, conn).await.into_iter() { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org if member.atype < MembershipType::Admin { if CONFIG.mail_enabled() { let org = Organization::find_by_uuid(&member.org_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; } let mut member = member; member.revoke(); member.save(conn).await?; log_event( EventType::OrganizationUserRevoked as i32, &member.uuid, &member.org_uuid, act_user_id, device_type, ip, conn, ) .await; } } Ok(()) } pub async fn enforce_2fa_policy_for_org( org_id: &OrganizationId, act_user_id: &UserId, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn, ) -> EmptyResult { let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); for member in Membership::find_confirmed_by_org(org_id, conn).await.into_iter() { // Don't enforce the policy for Admins and Owners. if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { if CONFIG.mail_enabled() { let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; } let mut member = member; member.revoke(); member.save(conn).await?; log_event( EventType::OrganizationUserRevoked as i32, &member.uuid, org_id, act_user_id, device_type, ip, conn, ) .await; } } Ok(()) } pub async fn send_incomplete_2fa_notifications(pool: DbPool) { debug!("Sending notifications for incomplete 2FA logins"); if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return; } let conn = match pool.get().await { Ok(conn) => conn, _ => { error!("Failed to get DB connection in send_incomplete_2fa_notifications()"); return; } }; let now = Utc::now().naive_utc(); let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); let time_before = now - time_limit; let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; for login in incomplete_logins { let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); info!( "User {} did not complete a 2FA login within the configured time limit. IP: {}", user.email, login.ip_address ); match mail::send_incomplete_2fa_login( &user.email, &login.ip_address, &login.login_time, &login.device_name, &DeviceType::from_i32(login.device_type).to_string(), ) .await { Ok(_) => { if let Err(e) = login.delete(&conn).await { error!("Error deleting incomplete 2FA record: {e:#?}"); } } Err(e) => { error!("Error sending incomplete 2FA email: {e:#?}"); } } } } // This function currently is just a dummy and the actual part is not implemented yet. // This also prevents 404 errors. // // See the following Bitwarden PR's regarding this feature. // https://github.com/bitwarden/clients/pull/2843 // https://github.com/bitwarden/clients/pull/2839 // https://github.com/bitwarden/server/pull/2016 // // The HTML part is hidden via the CSS patches done via the bw_web_build repo #[get("/two-factor/get-device-verification-settings")] fn get_device_verification_settings(_headers: Headers, _conn: DbConn) -> Json { Json(json!({ "isDeviceVerificationSectionEnabled":false, "unknownDeviceVerificationEnabled":false, "object":"deviceVerificationSettings" })) } ================================================ FILE: src/api/core/two_factor/protected_actions.rs ================================================ use chrono::{naive::serde::ts_seconds, NaiveDateTime, TimeDelta, Utc}; use rocket::{serde::json::Json, Route}; use crate::{ api::EmptyResult, auth::Headers, crypto, db::{ models::{TwoFactor, TwoFactorType, UserId}, DbConn, }, error::{Error, MapResult}, mail, CONFIG, }; pub fn routes() -> Vec { routes![request_otp, verify_otp] } /// Data stored in the TwoFactor table in the db #[derive(Debug, Serialize, Deserialize)] pub struct ProtectedActionData { /// Token issued to validate the protected action pub token: String, /// UNIX timestamp of token issue. #[serde(with = "ts_seconds")] pub token_sent: NaiveDateTime, // The total amount of attempts pub attempts: u64, } impl ProtectedActionData { pub fn new(token: String) -> Self { Self { token, token_sent: Utc::now().naive_utc(), attempts: 0, } } pub fn to_json(&self) -> String { serde_json::to_string(&self).unwrap() } pub fn from_json(string: &str) -> Result { let res: Result = serde_json::from_str(string); match res { Ok(x) => Ok(x), Err(_) => err!("Could not decode ProtectedActionData from string"), } } pub fn add_attempt(&mut self) { self.attempts = self.attempts.saturating_add(1); } pub fn time_since_sent(&self) -> TimeDelta { Utc::now().naive_utc() - self.token_sent } } #[post("/accounts/request-otp")] async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; if elapsed < delay { err!(format!("Please wait {} seconds before requesting another code.", (delay - elapsed))); } pa.delete(&conn).await?; } let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); let pa_data = ProtectedActionData::new(generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json()); twofactor.save(&conn).await?; mail::send_protected_action_token(&user.email, &pa_data.token).await?; Ok(()) } #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] struct ProtectedActionVerify { #[serde(rename = "OTP", alias = "otp")] otp: String, } #[post("/accounts/verify-otp", data = "")] async fn verify_otp(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); } let user = headers.user; let data: ProtectedActionVerify = data.into_inner(); // Delete the token after one validation attempt // This endpoint only gets called for the vault export, and doesn't need a second attempt validate_protected_action_otp(&data.otp, &user.uuid, true, &conn).await } pub async fn validate_protected_action_otp( otp: &str, user_id: &UserId, delete_if_valid: bool, conn: &DbConn, ) -> EmptyResult { let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; pa_data.add_attempt(); pa.data = pa_data.to_json(); // Fail after x attempts if the token has been used too many times. // Don't delete it, as we use it to keep track of attempts. if pa_data.attempts >= CONFIG.email_attempts_limit() { err!("Token has expired") } // Check if the token has expired (Using the email 2fa expiration time) let max_time = CONFIG.email_expiration_time() as i64; if pa_data.time_since_sent().num_seconds() > max_time { pa.delete(conn).await?; err!("Token has expired") } if !crypto::ct_eq(&pa_data.token, otp) { pa.save(conn).await?; err!("Token is invalid") } if delete_if_valid { pa.delete(conn).await?; } Ok(()) } ================================================ FILE: src/api/core/two_factor/webauthn.rs ================================================ use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::Headers, crypto::ct_eq, db::{ models::{EventType, TwoFactor, TwoFactorType, UserId}, DbConn, }, error::Error, util::NumberOrString, CONFIG, }; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; use std::str::FromStr; use std::sync::LazyLock; use std::time::Duration; use url::Url; use uuid::Uuid; use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration}; use webauthn_rs::{Webauthn, WebauthnBuilder}; use webauthn_rs_proto::{ AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs, RequestAuthenticationExtensions, UserVerificationPolicy, }; static WEBAUTHN: LazyLock = LazyLock::new(|| { let domain = CONFIG.domain(); let domain_origin = CONFIG.domain_origin(); let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(); let rp_origin = Url::parse(&domain_origin).unwrap(); let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) .expect("Creating WebauthnBuilder failed") .rp_name(&domain) .timeout(Duration::from_millis(60000)); webauthn.build().expect("Building Webauthn failed") }); pub fn routes() -> Vec { routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] } // Some old u2f structs still needed for migrating from u2f to WebAuthn // Both `struct Registration` and `struct U2FRegistration` can be removed if we remove the u2f to WebAuthn migration #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Registration { pub key_handle: Vec, pub pub_key: Vec, pub attestation_cert: Option>, pub device_name: Option, } #[derive(Serialize, Deserialize)] pub struct U2FRegistration { pub id: i32, pub name: String, #[serde(with = "Registration")] pub reg: Registration, pub counter: u32, compromised: bool, pub migrated: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct WebauthnRegistration { pub id: i32, pub name: String, pub migrated: bool, pub credential: Passkey, } impl WebauthnRegistration { fn to_json(&self) -> Value { json!({ "id": self.id, "name": self.name, "migrated": self.migrated, }) } fn set_backup_eligible(&mut self, backup_eligible: bool, backup_state: bool) -> bool { let mut changed = false; let mut cred: Credential = self.credential.clone().into(); if cred.backup_state != backup_state { cred.backup_state = backup_state; changed = true; } if backup_eligible && !cred.backup_eligible { cred.backup_eligible = true; changed = true; } self.credential = cred.into(); changed } } #[post("/two-factor/get-webauthn", data = "")] async fn get_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { if !CONFIG.domain_set() { err!("`DOMAIN` environment variable is not set. Webauthn disabled") } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ "enabled": enabled, "keys": registrations_json, "object": "twoFactorWebAuthn" }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let registrations = get_webauthn_registrations(&user.uuid, &conn) .await? .1 .into_iter() .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); let (mut challenge, state) = WEBAUTHN.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), Some(registrations), )?; let mut state = serde_json::to_value(&state)?; state["rs"]["policy"] = Value::String("discouraged".to_string()); state["rs"]["extensions"].as_object_mut().unwrap().clear(); let type_ = TwoFactorType::WebauthnRegisterChallenge; TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&conn).await?; // Because for this flow we abuse the passkeys as 2FA, and use it more like a securitykey // we need to modify some of the default settings defined by `start_passkey_registration()`. challenge.public_key.extensions = None; if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() { asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; } let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); Ok(Json(challenge_value)) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { id: NumberOrString, // 1..5 name: String, device_response: RegisterPublicKeyCredentialCopy, master_password_hash: Option, otp: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterPublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAttestationResponseRawCopy, pub r#type: String, } // This is copied from AuthenticatorAttestationResponseRaw to change clientDataJSON to clientDataJson #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthenticatorAttestationResponseRawCopy { #[serde(rename = "AttestationObject", alias = "attestationObject")] pub attestation_object: Base64UrlSafeData, #[serde(rename = "clientDataJson", alias = "clientDataJSON")] pub client_data_json: Base64UrlSafeData, } impl From for RegisterPublicKeyCredential { fn from(r: RegisterPublicKeyCredentialCopy) -> Self { Self { id: r.id, raw_id: r.raw_id, response: AuthenticatorAttestationResponseRaw { attestation_object: r.response.attestation_object, client_data_json: r.response.client_data_json, transports: None, }, type_: r.r#type, extensions: RegistrationExtensionsClientOutputs::default(), } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PublicKeyCredentialCopy { pub id: String, pub raw_id: Base64UrlSafeData, pub response: AuthenticatorAssertionResponseRawCopy, pub extensions: AuthenticationExtensionsClientOutputs, pub r#type: String, } // This is copied from AuthenticatorAssertionResponseRaw to change clientDataJSON to clientDataJson #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthenticatorAssertionResponseRawCopy { pub authenticator_data: Base64UrlSafeData, #[serde(rename = "clientDataJson", alias = "clientDataJSON")] pub client_data_json: Base64UrlSafeData, pub signature: Base64UrlSafeData, pub user_handle: Option, } impl From for PublicKeyCredential { fn from(r: PublicKeyCredentialCopy) -> Self { Self { id: r.id, raw_id: r.raw_id, response: AuthenticatorAssertionResponseRaw { authenticator_data: r.response.authenticator_data, client_data_json: r.response.client_data_json, signature: r.response.signature, user_handle: r.response.user_handle, }, extensions: r.extensions, type_: r.r#type, } } } #[post("/two-factor/webauthn", data = "")] async fn activate_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; PasswordOrOtpData { master_password_hash: data.master_password_hash, otp: data.otp, } .validate(&user, true, &conn) .await?; // Retrieve and delete the saved challenge state let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { Some(tf) => { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; state } None => err!("Can't recover challenge"), }; // Verify the credentials with the saved state let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { id: data.id.into_i32()?, name: data.name, migrated: false, credential, }); // Save the registrations and return them TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) .save(&conn) .await?; _generate_recover_code(&mut user, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ "enabled": true, "keys": keys_json, "object": "twoFactorU2f" }))) } #[put("/two-factor/webauthn", data = "")] async fn activate_webauthn_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { activate_webauthn(data, headers, conn).await } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DeleteU2FData { id: NumberOrString, master_password_hash: String, } #[delete("/two-factor/webauthn", data = "")] async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let id = data.id.into_i32()?; if !headers.user.check_valid_password(&data.master_password_hash) { err!("Invalid password"); } let Some(mut tf) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await else { err!("Webauthn data not found!") }; let mut data: Vec = serde_json::from_str(&tf.data)?; let Some(item_pos) = data.iter().position(|r| r.id == id) else { err!("Webauthn entry not found") }; let removed_item = data.remove(item_pos); tf.data = serde_json::to_string(&data)?; tf.save(&conn).await?; drop(tf); // If entry is migrated from u2f, delete the u2f entry as well if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await { let mut data: Vec = match serde_json::from_str(&u2f.data) { Ok(d) => d, Err(_) => err!("Error parsing U2F data"), }; data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); let new_data_str = serde_json::to_string(&data)?; u2f.data = new_data_str; u2f.save(&conn).await?; } let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ "enabled": true, "keys": keys_json, "object": "twoFactorU2f" }))) } pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { let type_ = TwoFactorType::Webauthn as i32; match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } } pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult { // Load saved credentials let creds: Vec = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect(); if creds.is_empty() { err!("No Webauthn devices registered") } // Generate a challenge based on the credentials let (mut response, state) = WEBAUTHN.start_passkey_authentication(&creds)?; // Modify to discourage user verification let mut state = serde_json::to_value(&state)?; state["ast"]["policy"] = Value::String("discouraged".to_string()); // Add appid, this is only needed for U2F compatibility, so maybe it can be removed as well let app_id = format!("{}/app-id.json", &CONFIG.domain()); state["ast"]["appid"] = Value::String(app_id.clone()); response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE; response .public_key .extensions .get_or_insert(RequestAuthenticationExtensions { appid: None, uvm: None, hmac_get_secret: None, }) .appid = Some(app_id); // Save the challenge state for later validation TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) .save(conn) .await?; // Return challenge to the clients Ok(Json(serde_json::to_value(response.public_key)?)) } pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { let type_ = TwoFactorType::WebauthnLoginChallenge as i32; let mut state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { Some(tf) => { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state } None => err!( "Can't recover login challenge", ErrorEvent { event: EventType::UserFailedLogIn2fa } ), }; let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?; let rsp: PublicKeyCredential = rsp.into(); let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; // We need to check for and update the backup_eligible flag when needed. // Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x // Because of this we check the flag at runtime and update the registrations and state when needed let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?; let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?; for reg in &mut registrations { if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) { // If the cred id matches and the credential is updated, Some(true) is returned // In those cases, update the record, else leave it alone let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true); if credential_updated || backup_flags_updated { TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) .save(conn) .await?; } return Ok(()); } } err!( "Credential not present", ErrorEvent { event: EventType::UserFailedLogIn2fa } ) } fn check_and_update_backup_eligible( rsp: &PublicKeyCredential, registrations: &mut Vec, state: &mut PasskeyAuthentication, ) -> Result { // The feature flags from the response // For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000; const FLAG_BACKUP_STATE: u8 = 0b0001_0000; if let Some(bits) = rsp.response.authenticator_data.get(32) { let backup_eligible = 0 != (bits & FLAG_BACKUP_ELIGIBLE); let backup_state = 0 != (bits & FLAG_BACKUP_STATE); // If the current key is backup eligible, then we probably need to update one of the keys already stored in the database // This is needed because Vaultwarden didn't store this information when using the previous version of webauthn-rs since it was a new addition to the protocol // Because we store multiple keys in one json string, we need to fetch the correct key first, and update its information before we let it verify if backup_eligible { let rsp_id = rsp.raw_id.as_slice(); for reg in &mut *registrations { if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) { if reg.set_backup_eligible(backup_eligible, backup_state) { // We also need to adjust the current state which holds the challenge used to start the authentication verification // Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update let mut raw_state = serde_json::to_value(&state)?; if let Some(credentials) = raw_state .get_mut("ast") .and_then(|v| v.get_mut("credentials")) .and_then(|v| v.as_array_mut()) { for cred in credentials.iter_mut() { if cred.get("cred_id").is_some_and(|v| { // Deserialize to a [u8] so it can be compared using `ct_eq` with the `rsp_id` let cred_id_slice: Base64UrlSafeData = serde_json::from_value(v.clone()).unwrap(); ct_eq(cred_id_slice, rsp_id) }) { cred["backup_eligible"] = Value::Bool(backup_eligible); cred["backup_state"] = Value::Bool(backup_state); } } } *state = serde_json::from_value(raw_state)?; return Ok(true); } break; } } } } Ok(false) } ================================================ FILE: src/api/core/two_factor/yubikey.rs ================================================ use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; use yubico::{config::Config, verify_async}; use crate::{ api::{ core::{log_user_event, two_factor::_generate_recover_code}, EmptyResult, JsonResult, PasswordOrOtpData, }, auth::Headers, db::{ models::{EventType, TwoFactor, TwoFactorType}, DbConn, }, error::{Error, MapResult}, CONFIG, }; pub fn routes() -> Vec { routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableYubikeyData { key1: Option, key2: Option, key3: Option, key4: Option, key5: Option, nfc: bool, master_password_hash: Option, otp: Option, } #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct YubikeyMetadata { #[serde(rename = "keys", alias = "Keys")] keys: Vec, #[serde(rename = "nfc", alias = "Nfc")] pub nfc: bool, } fn parse_yubikeys(data: &EnableYubikeyData) -> Vec { let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5]; data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect() } fn jsonify_yubikeys(yubikeys: Vec) -> Value { let mut result = Value::Object(serde_json::Map::new()); for (i, key) in yubikeys.into_iter().enumerate() { result[format!("Key{}", i + 1)] = Value::String(key); } result } fn get_yubico_credentials() -> Result<(String, String), Error> { if !CONFIG._enable_yubico() { err!("Yubico support is disabled"); } match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) { (Some(id), Some(secret)) => Ok((id, secret)), _ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"), } } async fn verify_yubikey_otp(otp: String) -> EmptyResult { let (yubico_id, yubico_secret) = get_yubico_credentials()?; let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret); match CONFIG.yubico_server() { Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await, None => verify_async(otp, config).await, } .map_res("Failed to verify OTP") } #[post("/two-factor/get-yubikey", data = "")] async fn generate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { // Make sure the credentials are set get_yubico_credentials()?; let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; let user_id = &user.uuid; let yubikey_type = TwoFactorType::YubiKey as i32; let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; if let Some(r) = r { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; let mut result = jsonify_yubikeys(yubikey_metadata.keys); result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); result["object"] = Value::String("twoFactorU2f".to_owned()); Ok(Json(result)) } else { Ok(Json(json!({ "enabled": false, "object": "twoFactorU2f", }))) } } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); let mut user = headers.user; PasswordOrOtpData { master_password_hash: data.master_password_hash.clone(), otp: data.otp.clone(), } .validate(&user, true, &conn) .await?; // Check if we already have some data let mut yubikey_data = match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { Some(data) => data, None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), }; let yubikeys = parse_yubikeys(&data); if yubikeys.is_empty() { return Ok(Json(json!({ "enabled": false, "object": "twoFactorU2f", }))); } // Ensure they are valid OTPs for yubikey in &yubikeys { if yubikey.is_empty() || yubikey.len() == 12 { continue; } verify_yubikey_otp(yubikey.to_owned()).await.map_res("Invalid Yubikey OTP provided")?; } let yubikey_ids: Vec = yubikeys.into_iter().filter_map(|x| x.get(..12).map(str::to_owned)).collect(); let yubikey_metadata = YubikeyMetadata { keys: yubikey_ids, nfc: data.nfc, }; yubikey_data.data = serde_json::to_string(&yubikey_metadata).unwrap(); yubikey_data.save(&conn).await?; _generate_recover_code(&mut user, &conn).await; log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); result["object"] = Value::String("twoFactorU2f".to_owned()); Ok(Json(result)) } #[put("/two-factor/yubikey", data = "")] async fn activate_yubikey_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { activate_yubikey(data, headers, conn).await } pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { if response.len() != 44 { err!("Invalid Yubikey OTP length"); } let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata"); let response_id = &response[..12]; if !yubikey_metadata.keys.contains(&response_id.to_owned()) { err!("Given Yubikey is not registered"); } verify_yubikey_otp(response.to_owned()).await.map_res("Failed to verify Yubikey against OTP server")?; Ok(()) } ================================================ FILE: src/api/icons.rs ================================================ use std::{ collections::HashMap, net::IpAddr, sync::{Arc, LazyLock}, time::{Duration, SystemTime}, }; use bytes::{Bytes, BytesMut}; use futures::{stream::StreamExt, TryFutureExt}; use html5gum::{Emitter, HtmlString, Readable, StringReader, Tokenizer}; use regex::Regex; use reqwest::{ header::{self, HeaderMap, HeaderValue}, Client, Response, }; use rocket::{http::ContentType, response::Redirect, Route}; use svg_hush::{data_url_filter, Filter}; use crate::{ config::PathType, error::Error, http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, util::Cached, CONFIG, }; pub fn routes() -> Vec { match CONFIG.icon_service().as_str() { "internal" => routes![icon_internal], _ => routes![icon_external], } } static CLIENT: LazyLock = LazyLock::new(|| { // Generate the default headers let mut default_headers = HeaderMap::new(); default_headers.insert( header::USER_AGENT, HeaderValue::from_static( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", ), ); default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")); default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); default_headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); default_headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); default_headers.insert(header::UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1")); default_headers.insert("Sec-Ch-Ua-Mobile", HeaderValue::from_static("?0")); default_headers.insert("Sec-Ch-Ua-Platform", HeaderValue::from_static("Linux")); default_headers.insert( "Sec-Ch-Ua", HeaderValue::from_static("\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\""), ); default_headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none")); default_headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate")); default_headers.insert("Sec-Fetch-User", HeaderValue::from_static("?1")); default_headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document")); // Generate the cookie store let cookie_store = Arc::new(Jar::default()); let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests get_reqwest_client_builder() .cookie_provider(Arc::clone(&cookie_store)) .timeout(icon_download_timeout) .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections .pool_idle_timeout(pool_idle_timeout) // Configure the Hyper Pool to timeout after 10 seconds .default_headers(default_headers.clone()) .http1_title_case_headers() .build() .expect("Failed to build client") }); // Build Regex only once since this takes a lot of time. static ICON_SIZE_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); // The function name `icon_external` is checked in the `on_response` function in `AppHeaders` // It is used to prevent sending a specific header which breaks icon downloads. // If this function needs to be renamed, also adjust the code in `util.rs` #[get("//icon.png")] fn icon_external(domain: &str) -> Cached> { if !is_valid_domain(domain) { warn!("Invalid domain: {domain}"); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); } if should_block_address(domain) { warn!("Blocked address: {domain}"); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); } let url = CONFIG._icon_service_url().replace("{}", domain); let redir = match CONFIG.icon_redirect_code() { 301 => Some(Redirect::moved(url)), // legacy permanent redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect 307 => Some(Redirect::temporary(url)), 308 => Some(Redirect::permanent(url)), _ => { error!("Unexpected redirect code {}", CONFIG.icon_redirect_code()); None } }; Cached::ttl(redir, CONFIG.icon_cache_ttl(), true) } #[get("//icon.png")] async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec)> { const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); if !is_valid_domain(domain) { warn!("Invalid domain: {domain}"); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), true, ); } if should_block_address(domain) { warn!("Blocked address: {domain}"); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), true, ); } match get_icon(domain).await { Some((icon, icon_type)) => { Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) } _ => Cached::ttl((ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), CONFIG.icon_cache_negttl(), true), } } /// Returns if the domain provided is valid or not. /// /// This does some manual checks and makes use of Url to do some basic checking. /// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. fn is_valid_domain(domain: &str) -> bool { const ALLOWED_CHARS: &str = "-."; // If parsing the domain fails using Url, it will not work with reqwest. if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) { debug!("Domain parse error: '{domain}' - {parse_error:?}"); return false; } else if domain.is_empty() || domain.contains("..") || domain.starts_with('.') || domain.starts_with('-') || domain.ends_with('-') { debug!( "Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'" ); return false; } else if domain.len() > 255 { debug!("Domain validation error: '{domain}' exceeds 255 characters"); return false; } for c in domain.chars() { if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) { debug!("Domain validation error: '{domain}' contains an invalid character '{c}'"); return false; } } true } async fn get_icon(domain: &str) -> Option<(Vec, String)> { let path = format!("{domain}.png"); // Check for expiration of negatively cached copy if icon_is_negcached(&path).await { return None; } if let Some(icon) = get_cached_icon(&path).await { let icon_type = get_icon_type(&icon).unwrap_or("x-icon"); return Some((icon, icon_type.to_string())); } if CONFIG.disable_icon_download() { return None; } // Get the icon, or None in case of error match download_icon(domain).await { Ok((icon, icon_type)) => { save_icon(&path, icon.to_vec()).await; Some((icon.to_vec(), icon_type.unwrap_or("x-icon").to_string())) } Err(e) => { // If this error comes from the custom resolver, this means this is a blocked domain // or non global IP, don't save the miss file in this case to avoid leaking it if let Some(error) = CustomHttpClientError::downcast_ref(&e) { warn!("{error}"); return None; } warn!("Unable to download icon: {e:?}"); let miss_indicator = path + ".miss"; save_icon(&miss_indicator, vec![]).await; None } } } async fn get_cached_icon(path: &str) -> Option> { // Check for expiration of successfully cached copy if icon_is_expired(path).await { return None; } // Try to read the cached icon, and return it if it exists if let Ok(operator) = CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { if let Ok(buf) = operator.read(path).await { return Some(buf.to_vec()); } } None } async fn file_is_expired(path: &str, ttl: u64) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let meta = operator.stat(path).await?; let modified = meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; let age = SystemTime::now().duration_since(modified.into())?; Ok(ttl > 0 && ttl <= age.as_secs()) } async fn icon_is_negcached(path: &str) -> bool { let miss_indicator = path.to_owned() + ".miss"; let expired = file_is_expired(&miss_indicator, CONFIG.icon_cache_negttl()).await; match expired { // No longer negatively cached, drop the marker Ok(true) => { match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { Ok(operator) => { if let Err(e) = operator.delete(&miss_indicator).await { error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"); } } Err(e) => error!("Could not remove negative cache indicator for icon {path:?}: {e:?}"), } false } // The marker hasn't expired yet. Ok(false) => true, // The marker is missing or inaccessible in some way. Err(_) => false, } } async fn icon_is_expired(path: &str) -> bool { let expired = file_is_expired(path, CONFIG.icon_cache_ttl()).await; expired.unwrap_or(true) } struct Icon { priority: u8, href: String, } impl Icon { const fn new(priority: u8, href: String) -> Self { Self { priority, href, } } } fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &mut Vec, url: &url::Url) { const TAG_LINK: &[u8] = b"link"; const TAG_BASE: &[u8] = b"base"; const TAG_HEAD: &[u8] = b"head"; const ATTR_HREF: &[u8] = b"href"; const ATTR_SIZES: &[u8] = b"sizes"; let mut base_url = url.clone(); let mut icon_tags: Vec = Vec::new(); for Ok(token) in dom { let tag_name: &[u8] = &token.tag.name; match tag_name { TAG_LINK => { icon_tags.push(token.tag); } TAG_BASE => { base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) { let href = std::str::from_utf8(href).unwrap_or_default(); debug!("Found base href: {href}"); match base_url.join(href) { Ok(inner_url) => inner_url, _ => continue, } } else { continue; }; } TAG_HEAD if token.closing => { break; } _ => {} } } for icon_tag in icon_tags { if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) { if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) { let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { std::str::from_utf8(v).unwrap_or_default() } else { "" }; let priority = get_icon_priority(full_href.as_str(), sizes); icons.push(Icon::new(priority, full_href.to_string())); } }; } } struct IconUrlResult { iconlist: Vec, referer: String, } /// Returns a IconUrlResult which holds a Vector IconList and a string which holds the referer. /// There will always two items within the iconlist which holds http(s)://domain.tld/favicon.ico. /// This does not mean that location exists, but (it) is the default location the browser uses. /// /// # Argument /// * `domain` - A string which holds the domain with extension. /// /// # Example /// ``` /// let icon_result = get_icon_url("github.com").await?; /// let icon_result = get_icon_url("vaultwarden.discourse.group").await?; /// ``` async fn get_icon_url(domain: &str) -> Result { // Default URL with secure and insecure schemes let ssldomain = format!("https://{domain}"); let httpdomain = format!("http://{domain}"); // First check the domain as given during the request for HTTPS. let resp = match get_page(&ssldomain).await { Err(e) if CustomHttpClientError::downcast_ref(&e).is_none() => { // If we get an error that is not caused by the blacklist, we retry with HTTP match get_page(&httpdomain).await { mut sub_resp @ Err(_) => { // When the domain is not an IP, and has more then one dot, remove all subdomains. let is_ip = domain.parse::(); if is_ip.is_err() && domain.matches('.').count() > 1 { let mut domain_parts = domain.split('.'); let base_domain = format!( "{base}.{tld}", tld = domain_parts.next_back().unwrap(), base = domain_parts.next_back().unwrap() ); if is_valid_domain(&base_domain) { let sslbase = format!("https://{base_domain}"); let httpbase = format!("http://{base_domain}"); debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); sub_resp = get_page(&sslbase).or_else(|_| get_page(&httpbase)).await; } // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. } else if is_ip.is_err() && domain.matches('.').count() < 2 { let www_domain = format!("www.{domain}"); if is_valid_domain(&www_domain) { let sslwww = format!("https://{www_domain}"); let httpwww = format!("http://{www_domain}"); debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); sub_resp = get_page(&sslwww).or_else(|_| get_page(&httpwww)).await; } } sub_resp } res => res, } } // If we get a result or a blacklist error, just continue res => res, }; // Create the iconlist let mut iconlist: Vec = Vec::new(); let mut referer = String::new(); if let Ok(content) = resp { // Extract the URL from the response in case redirects occurred (like @ gitlab.com) let url = content.url().clone(); // Set the referer to be used on the final request, some sites check this. // Mostly used to prevent direct linking and other security reasons. referer = url.to_string(); // Add the fallback favicon.ico and apple-touch-icon.png to the list with the domain the content responded from. iconlist.push(Icon::new(35, String::from(url.join("/favicon.ico").unwrap()))); iconlist.push(Icon::new(40, String::from(url.join("/apple-touch-icon.png").unwrap()))); // 384KB should be more than enough for the HTML, though as we only really need the HTML header. let limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.to_vec(); let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()); get_favicons_node(dom, &mut iconlist, &url); } else { // Add the default favicon.ico to the list with just the given domain iconlist.push(Icon::new(35, format!("{ssldomain}/favicon.ico"))); iconlist.push(Icon::new(40, format!("{ssldomain}/apple-touch-icon.png"))); iconlist.push(Icon::new(35, format!("{httpdomain}/favicon.ico"))); iconlist.push(Icon::new(40, format!("{httpdomain}/apple-touch-icon.png"))); } // Sort the iconlist by priority iconlist.sort_by_key(|x| x.priority); // There always is an icon in the list, so no need to check if it exists, and just return the first one Ok(IconUrlResult { iconlist, referer, }) } async fn get_page(url: &str) -> Result { get_page_with_referer(url, "").await } async fn get_page_with_referer(url: &str, referer: &str) -> Result { let mut client = CLIENT.get(url); if !referer.is_empty() { client = client.header("Referer", referer) } Ok(client.send().await?.error_for_status()?) } /// Returns a Integer with the priority of the type of the icon which to prefer. /// The lower the number the better. /// /// # Arguments /// * `href` - A string which holds the href value or relative path. /// * `sizes` - The size of the icon if available as a x value like 32x32. /// /// # Example /// ``` /// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32"); /// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", ""); /// ``` fn get_icon_priority(href: &str, sizes: &str) -> u8 { static PRIORITY_MAP: LazyLock> = LazyLock::new(|| [(".png", 10), (".jpg", 20), (".jpeg", 20)].into_iter().collect()); // Check if there is a dimension set let (width, height) = parse_sizes(sizes); // Check if there is a size given if width != 0 && height != 0 { // Only allow square dimensions if width == height { // Change priority by given size if width == 32 { 1 } else if width == 64 { 2 } else if (24..=192).contains(&width) { 3 } else if width == 16 { 4 } else { 5 } // There are dimensions available, but the image is not a square } else { 200 } } else { match href.rsplit_once('.') { Some((_, extension)) => PRIORITY_MAP.get(&*extension.to_ascii_lowercase()).copied().unwrap_or(30), None => 30, } } } /// Returns a Tuple with the width and height as a separate value extracted from the sizes attribute /// It will return 0 for both values if no match has been found. /// /// # Arguments /// * `sizes` - The size of the icon if available as a x value like 32x32. /// /// # Example /// ``` /// let (width, height) = parse_sizes("64x64"); // (64, 64) /// let (width, height) = parse_sizes("x128x128"); // (128, 128) /// let (width, height) = parse_sizes("32"); // (0, 0) /// ``` fn parse_sizes(sizes: &str) -> (u16, u16) { let mut width: u16 = 0; let mut height: u16 = 0; if !sizes.is_empty() { match ICON_SIZE_REGEX.captures(sizes.trim()) { Some(dimensions) if dimensions.len() >= 3 => { width = dimensions[1].parse::().unwrap_or_default(); height = dimensions[2].parse::().unwrap_or_default(); } _ => {} } } (width, height) } async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { let icon_result = get_icon_url(domain).await?; let mut buffer = Bytes::new(); let mut icon_type: Option<&str> = None; use data_url::DataUrl; for icon in icon_result.iconlist.iter().take(5) { if icon.href.starts_with("data:image") { let Ok(datauri) = DataUrl::process(&icon.href) else { continue; }; // Check if we are able to decode the data uri let mut body = BytesMut::new(); match datauri.decode::<_, ()>(|bytes| { body.extend_from_slice(bytes); Ok(()) }) { Ok(_) => { // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&body); if icon_type.is_none() { debug!("Icon from {domain} data:image uri, is not a valid image type"); continue; } info!("Extracted icon from data:image uri for {domain}"); buffer = body.freeze(); break; } } _ => debug!("Extracted icon from data:image uri is invalid"), }; } else { let res = get_page_with_referer(&icon.href, &icon_result.referer).await?; buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&buffer); if icon_type.is_none() { buffer.clear(); debug!("Icon from {}, is not a valid image type", icon.href); continue; } info!("Downloaded icon from {}", icon.href); break; } } if buffer.is_empty() { err_silent!("Empty response or unable find a valid icon", domain); } else if icon_type == Some("svg+xml") { let mut svg_filter = Filter::new(); svg_filter.set_data_url_filter(data_url_filter::allow_standard_images); let mut sanitized_svg = Vec::new(); if svg_filter.filter(&*buffer, &mut sanitized_svg).is_err() { icon_type = None; buffer.clear(); } else { buffer = sanitized_svg.into(); } } Ok((buffer, icon_type)) } async fn save_icon(path: &str, icon: Vec) { let operator = match CONFIG.opendal_operator_for_path_type(&PathType::IconCache) { Ok(operator) => operator, Err(e) => { warn!("Failed to get OpenDAL operator while saving icon: {e}"); return; } }; if let Err(e) = operator.write(path, icon).await { warn!("Unable to save icon: {e:?}"); } } fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { fn check_svg_after_xml_declaration(bytes: &[u8]) -> Option<&'static str> { // Look for SVG tag within the first 1KB if let Ok(content) = std::str::from_utf8(&bytes[..bytes.len().min(1024)]) { if content.contains(" Some("png"), [0, 0, 1, 0, ..] => Some("x-icon"), [82, 73, 70, 70, ..] => Some("webp"), [255, 216, 255, ..] => Some("jpeg"), [71, 73, 70, 56, ..] => Some("gif"), [66, 77, ..] => Some("bmp"), [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg [60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with None, } } /// Minimize the amount of bytes to be parsed from a reqwest result. /// This prevents very long parsing and memory usage. async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result { let mut stream = res.bytes_stream().take(max_size); let mut buf = BytesMut::new(); let mut size = 0; while let Some(chunk) = stream.next().await { // It is possible that there might occur UnexpectedEof errors or others // This is most of the time no issue, and if there is no chunked data anymore or at all parsing the HTML will not happen anyway. // Therefore if chunk is an err, just break and continue with the data be have received. if chunk.is_err() { break; } let chunk = &chunk?; size += chunk.len(); buf.extend(chunk); if size >= max_size { break; } } Ok(buf.freeze()) } /// This is an implementation of the default Cookie Jar from Reqwest and reqwest_cookie_store build by pfernie. /// The default cookie jar used by Reqwest keeps all the cookies based upon the Max-Age or Expires which could be a long time. /// That could be used for tracking, to prevent this we force the lifespan of the cookies to always be max two minutes. /// A Cookie Jar is needed because some sites force a redirect with cookies to verify if a request uses cookies or not. use cookie_store::CookieStore; #[derive(Default)] pub struct Jar(std::sync::RwLock); impl reqwest::cookie::CookieStore for Jar { fn set_cookies(&self, cookie_headers: &mut dyn Iterator, url: &url::Url) { use cookie::{Cookie as RawCookie, ParseError as RawCookieParseError}; use time::Duration; let mut cookie_store = self.0.write().unwrap(); let cookies = cookie_headers.filter_map(|val| { std::str::from_utf8(val.as_bytes()) .map_err(RawCookieParseError::from) .and_then(RawCookie::parse) .map(|mut c| { c.set_expires(None); c.set_max_age(Some(Duration::minutes(2))); c.into_owned() }) .ok() }); cookie_store.store_response_cookies(cookies, url); } fn cookies(&self, url: &url::Url) -> Option { let cookie_store = self.0.read().unwrap(); let s = cookie_store .get_request_values(url) .map(|(name, value)| format!("{name}={value}")) .collect::>() .join("; "); if s.is_empty() { return None; } HeaderValue::from_maybe_shared(Bytes::from(s)).ok() } } /// Custom FaviconEmitter for the html5gum parser. /// The FaviconEmitter is using an optimized version of the DefaultEmitter. /// This prevents emitting tags like comments, doctype and also strings between the tags. /// But it will also only emit the tags we need and only if they have the correct attributes /// Therefore parsing the HTML content is faster. use std::collections::BTreeMap; #[derive(Default)] pub struct Tag { /// The tag's name, such as `"link"` or `"base"`. pub name: HtmlString, /// A mapping for any HTML attributes this start tag may have. /// /// Duplicate attributes are ignored after the first one as per WHATWG spec. pub attributes: BTreeMap, } struct FaviconToken { tag: Tag, closing: bool, } #[derive(Default)] struct FaviconEmitter { current_token: Option, last_start_tag: HtmlString, current_attribute: Option<(HtmlString, HtmlString)>, emit_token: bool, } impl FaviconEmitter { fn flush_current_attribute(&mut self, emit_current_tag: bool) { const ATTR_HREF: &[u8] = b"href"; const ATTR_REL: &[u8] = b"rel"; const TAG_LINK: &[u8] = b"link"; const TAG_BASE: &[u8] = b"base"; const TAG_HEAD: &[u8] = b"head"; if let Some(ref mut token) = self.current_token { let tag_name: &[u8] = &token.tag.name; if self.current_attribute.is_some() && (tag_name == TAG_BASE || tag_name == TAG_LINK) { let (k, v) = self.current_attribute.take().unwrap(); token.tag.attributes.entry(k).and_modify(|_| {}).or_insert(v); } let tag_attr = &token.tag.attributes; match tag_name { TAG_HEAD if token.closing => self.emit_token = true, TAG_BASE if tag_attr.contains_key(ATTR_HREF) => self.emit_token = true, TAG_LINK if emit_current_tag && tag_attr.contains_key(ATTR_REL) && tag_attr.contains_key(ATTR_HREF) => { let rel_value = std::str::from_utf8(token.tag.attributes.get(ATTR_REL).unwrap()).unwrap_or_default(); if rel_value.contains("icon") && !rel_value.contains("mask-icon") { self.emit_token = true } } _ => (), } } } } impl Emitter for FaviconEmitter { type Token = FaviconToken; fn set_last_start_tag(&mut self, last_start_tag: Option<&[u8]>) { self.last_start_tag.clear(); self.last_start_tag.extend(last_start_tag.unwrap_or_default()); } fn pop_token(&mut self) -> Option { if self.emit_token { self.emit_token = false; return self.current_token.take(); } None } fn init_start_tag(&mut self) { self.current_token = Some(FaviconToken { tag: Tag::default(), closing: false, }); } fn init_end_tag(&mut self) { self.current_token = Some(FaviconToken { tag: Tag::default(), closing: true, }); } fn emit_current_tag(&mut self) -> Option { self.flush_current_attribute(true); self.last_start_tag.clear(); match &self.current_token { Some(token) if !token.closing => { self.last_start_tag.extend(&*token.tag.name); } _ => {} } html5gum::naive_next_state(&self.last_start_tag) } fn push_tag_name(&mut self, s: &[u8]) { if let Some(ref mut token) = self.current_token { token.tag.name.extend(s); } } fn init_attribute(&mut self) { self.flush_current_attribute(false); self.current_attribute = match &self.current_token { Some(token) => { let tag_name: &[u8] = &token.tag.name; match tag_name { b"link" | b"head" | b"base" => Some(Default::default()), _ => None, } } _ => None, }; } fn push_attribute_name(&mut self, s: &[u8]) { if let Some(attr) = &mut self.current_attribute { attr.0.extend(s) } } fn push_attribute_value(&mut self, s: &[u8]) { if let Some(attr) = &mut self.current_attribute { attr.1.extend(s) } } fn current_is_appropriate_end_tag_token(&mut self) -> bool { match &self.current_token { Some(token) if token.closing => !self.last_start_tag.is_empty() && self.last_start_tag == token.tag.name, _ => false, } } // We do not want and need these parts of the HTML document // These will be skipped and ignored during the tokenization and iteration. fn emit_current_comment(&mut self) {} fn emit_current_doctype(&mut self) {} fn emit_eof(&mut self) {} fn emit_error(&mut self, _: html5gum::Error) {} fn emit_string(&mut self, _: &[u8]) {} fn init_comment(&mut self) {} fn init_doctype(&mut self) {} fn push_comment(&mut self, _: &[u8]) {} fn push_doctype_name(&mut self, _: &[u8]) {} fn push_doctype_public_identifier(&mut self, _: &[u8]) {} fn push_doctype_system_identifier(&mut self, _: &[u8]) {} fn set_doctype_public_identifier(&mut self, _: &[u8]) {} fn set_doctype_system_identifier(&mut self, _: &[u8]) {} fn set_force_quirks(&mut self) {} fn set_self_closing(&mut self) {} } ================================================ FILE: src/api/identity.rs ================================================ use chrono::Utc; use num_traits::FromPrimitive; use rocket::{ form::{Form, FromForm}, http::Status, response::Redirect, serde::json::Json, Route, }; use serde_json::Value; use crate::{ api::{ core::{ accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, master_password_policy, push::register_push_device, ApiResult, EmptyResult, JsonResult, }, auth, auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, db::{ models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, DbConn, }, error::MapResult, mail, sso, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, util, CONFIG, }; pub fn routes() -> Vec { routes![ login, prelogin, identity_register, register_verification_email, register_finish, prevalidate, authorize, oidcsignin, oidcsignin_error ] } #[post("/connect/token", data = "")] async fn login( data: Form, client_header: ClientHeaders, client_version: Option, conn: DbConn, ) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_id: Option = None; let login_result = match data.grant_type.as_ref() { "refresh_token" => { _check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; _refresh_login(data, &conn, &client_header.ip).await } "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.password, "password cannot be blank")?; _check_is_some(&data.scope, "scope cannot be blank")?; _check_is_some(&data.username, "username cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; _password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } "client_credentials" => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_secret, "client_secret cannot be blank")?; _check_is_some(&data.scope, "scope cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; _api_key_login(data, &mut user_id, &conn, &client_header.ip).await } "authorization_code" if CONFIG.sso_enabled() => { _check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.code, "code cannot be blank")?; _check_is_some(&data.code_verifier, "code verifier cannot be blank")?; _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; _sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await } "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), }; if let Some(user_id) = user_id { match &login_result { Ok(_) => { log_user_event( EventType::UserLoggedIn as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn, ) .await; } Err(e) => { if let Some(ev) = e.get_event() { log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) .await } } } } login_result } // Return Status::Unauthorized to trigger logout async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Extract token let refresh_token = match data.refresh_token { Some(token) => token, None => err_code!("Missing refresh_token", Status::Unauthorized.code), }; // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { Err(err) => { err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) } Ok((mut device, auth_tokens)) => { // Save to update `device.updated_at` to track usage and toggle new status device.save(true, conn).await?; let result = json!({ "refresh_token": auth_tokens.refresh_token(), "access_token": auth_tokens.access_token(), "expires_in": auth_tokens.expires_in(), "token_type": "Bearer", "scope": auth_tokens.scope(), }); Ok(Json(result)) } } } // After exchanging the code we need to check first if 2FA is needed before continuing async fn _sso_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp, client_version: &Option, ) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { (None, _) => err!( "Got no code in OIDC data", ErrorEvent { event: EventType::UserFailedLogIn } ), (_, None) => err!( "Got no code verifier in OIDC data", ErrorEvent { event: EventType::UserFailedLogIn } ), (Some(code), Some(code_verifier)) => (code, code_verifier.clone()), }; let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?; let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { None => match SsoUser::find_by_mail(&user_infos.email, conn).await { None => None, Some((user, Some(_))) => { error!( "Login failure ({}), existing SSO user ({}) with same email ({})", user_infos.identifier, user.uuid, user.email ); err_silent!( "Existing SSO user with same email", ErrorEvent { event: EventType::UserFailedLogIn } ) } Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => { error!( "Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled", user_infos.identifier, user.uuid, user.email ); err_silent!( "Existing non SSO user with same email", ErrorEvent { event: EventType::UserFailedLogIn } ) } Some((user, None)) => Some((user, None)), }, Some((user, sso_user)) => Some((user, Some(sso_user))), }; let now = Utc::now().naive_utc(); // Will trigger 2FA flow if needed let (user, mut device, twofactor_token, sso_user) = match user_with_sso { None => { if !CONFIG.is_email_domain_allowed(&user_infos.email) { err!( "Email domain not allowed", ErrorEvent { event: EventType::UserFailedLogIn } ); } match user_infos.email_verified { None if !CONFIG.sso_allow_unknown_email_verification() => err!( "Your provider does not send email verification status.\n\ You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.", ErrorEvent { event: EventType::UserFailedLogIn } ), Some(false) => err!( "You need to verify your email with your provider before you can log in", ErrorEvent { event: EventType::UserFailedLogIn } ), _ => (), } let mut user = User::new(&user_infos.email, user_infos.user_name.clone()); user.verified_at = Some(now); user.save(conn).await?; let device = get_device(&data, conn, &user).await?; (user, device, None, None) } Some((user, _)) if !user.enabled => { err!( "This user has been disabled", format!("IP: {}. Username: {}.", ip.ip, user.display_name()), ErrorEvent { event: EventType::UserFailedLogIn } ) } Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; if user.private_key.is_none() { // User was invited a stub was created user.verified_at = Some(now); if let Some(ref user_name) = user_infos.user_name { user.name = user_name.clone(); } user.save(conn).await?; } if user.email != user_infos.email { if CONFIG.mail_enabled() { mail::send_sso_change_email(&user_infos.email).await?; } info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email); } (user, device, twofactor_token, sso_user) } }; // Set the user_uuid here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); // We passed 2FA get auth tokens let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } async fn _password_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp, client_version: &Option, ) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; // Get the user let username = data.username.as_ref().unwrap().trim(); let Some(mut user) = User::find_by_mail(username, conn).await else { err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip)) }; // Set the user_id here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); // Check if the user is disabled if !user.enabled { err!( "This user has been disabled", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } ) } let password = data.password.as_ref().unwrap(); // If we get an auth request, we don't check the user's password, but the access code of the auth request if let Some(ref auth_request_id) = data.auth_request { let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { err!( "Auth request not found. Try again.", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) }; let expiration_time = auth_request.creation_date + chrono::Duration::minutes(5); let request_expired = Utc::now().naive_utc() >= expiration_time; if auth_request.user_uuid != user.uuid || !auth_request.approved.unwrap_or(false) || request_expired || ip.ip.to_string() != auth_request.request_ip || !auth_request.check_access_code(password) { err!( "Username or access code is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) } } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } ) } // Change the KDF Iterations (only when not logging in with an auth request) if data.auth_request.is_none() { kdf_upgrade(&mut user, password, conn).await?; } let now = Utc::now().naive_utc(); if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { 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 { // We want to send another email verification if we require signups to verify // their email address, and we haven't sent them a reminder in a while... user.last_verifying_at = Some(now); user.login_verify_count += 1; if let Err(e) = user.save(conn).await { error!("Error updating user: {e:#?}"); } if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await { error!("Error auto-sending email verification email: {e:#?}"); } } } // We still want the login to fail until they actually verified the email address err!( "Please verify your email before trying again.", format!("IP: {}. Username: {username}.", ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } ) } let mut device = get_device(&data, conn, &user).await?; let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await } async fn authenticated_response( user: &User, device: &mut Device, auth_tokens: auth::AuthTokens, twofactor_token: Option, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { if CONFIG.mail_enabled() && device.is_new() { let now = Utc::now().naive_utc(); if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, device).await { error!("Error sending new device email: {e:#?}"); if CONFIG.require_device_email() { err!( "Could not send login notification email. Please contact your administrator.", ErrorEvent { event: EventType::UserFailedLogIn } ) } } } // register push device if !device.is_new() { register_push_device(device, conn).await?; } // Save to update `device.updated_at` to track usage and toggle new status device.save(true, conn).await?; let master_password_policy = master_password_policy(user, conn).await; let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { json!({ "Kdf": { "KdfType": user.client_kdf_type, "Iterations": user.client_kdf_iter, "Memory": user.client_kdf_memory, "Parallelism": user.client_kdf_parallelism }, // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, "Salt": user.email }) } else { Value::Null }; let account_keys = if user.private_key.is_some() { json!({ "publicKeyEncryptionKeyPair": { "wrappedPrivateKey": user.private_key, "publicKey": user.public_key, "Object": "publicKeyEncryptionKeyPair" }, "Object": "privateKeys" }) } else { Value::Null }; let mut result = json!({ "access_token": auth_tokens.access_token(), "expires_in": auth_tokens.expires_in(), "token_type": "Bearer", "refresh_token": auth_tokens.refresh_token(), "PrivateKey": user.private_key, "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: Same as above "ForcePasswordReset": false, "MasterPasswordPolicy": master_password_policy, "scope": auth_tokens.scope(), "AccountKeys": account_keys, "UserDecryptionOptions": { "HasMasterPassword": has_master_password, "MasterPasswordUnlock": master_password_unlock, "Object": "userDecryptionOptions" }, }); if !user.akey.is_empty() { result["Key"] = Value::String(user.akey.clone()); } if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip); Ok(Json(result)) } async fn _api_key_login(data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; // Validate scope match data.scope.as_ref() { Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await, Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await, _ => err!("Scope not supported"), } } async fn _user_api_key_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, ip: &ClientIp, ) -> JsonResult { // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(client_user_id) = client_id.strip_prefix("user.") else { err!("Malformed client_id", format!("IP: {}.", ip.ip)) }; let client_user_id: UserId = client_user_id.into(); let Some(user) = User::find_by_uuid(&client_user_id, conn).await else { err!("Invalid client_id", format!("IP: {}.", ip.ip)) }; // Set the user_id here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); // Check if the user is disabled if !user.enabled { err!( "This user has been disabled (API key login)", format!("IP: {}. Username: {}.", ip.ip, user.email), ErrorEvent { event: EventType::UserFailedLogIn } ) } // Check API key. Note that API key logins bypass 2FA. let client_secret = data.client_secret.as_ref().unwrap(); if !user.check_valid_api_key(client_secret) { err!( "Incorrect client_secret", format!("IP: {}. Username: {}.", ip.ip, user.email), ErrorEvent { event: EventType::UserFailedLogIn } ) } let mut device = get_device(&data, conn, &user).await?; if CONFIG.mail_enabled() && device.is_new() { let now = Utc::now().naive_utc(); if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { error!("Error sending new device email: {e:#?}"); if CONFIG.require_device_email() { err!( "Could not send login notification email. Please contact your administrator.", ErrorEvent { event: EventType::UserFailedLogIn } ) } } } // --- // Disabled this variable, it was used to generate the JWT // Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await; let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id); // Save to update `device.updated_at` to track usage and toggle new status device.save(true, conn).await?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { json!({ "Kdf": { "KdfType": user.client_kdf_type, "Iterations": user.client_kdf_iter, "Memory": user.client_kdf_memory, "Parallelism": user.client_kdf_parallelism }, // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 "MasterKeyEncryptedUserKey": user.akey, "MasterKeyWrappedUserKey": user.akey, "Salt": user.email }) } else { Value::Null }; let account_keys = if user.private_key.is_some() { json!({ "publicKeyEncryptionKeyPair": { "wrappedPrivateKey": user.private_key, "publicKey": user.public_key, "Object": "publicKeyEncryptionKeyPair" }, "Object": "privateKeys" }) } else { Value::Null }; // Note: No refresh_token is returned. The CLI just repeats the // client_credentials login flow when the existing token expires. let result = json!({ "access_token": access_claims.token(), "expires_in": access_claims.expires_in(), "token_type": "Bearer", "Key": user.akey, "PrivateKey": user.private_key, "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing "ForcePasswordReset": false, "scope": AuthMethod::UserApiKey.scope(), "AccountKeys": account_keys, "UserDecryptionOptions": { "HasMasterPassword": has_master_password, "MasterPasswordUnlock": master_password_unlock, "Object": "userDecryptionOptions" }, }); Ok(Json(result)) } async fn _organization_api_key_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { // Get the org via the client_id let client_id = data.client_id.as_ref().unwrap(); let Some(org_id) = client_id.strip_prefix("organization.") else { err!("Malformed client_id", format!("IP: {}.", ip.ip)) }; let org_id: OrganizationId = org_id.to_string().into(); let Some(org_api_key) = OrganizationApiKey::find_by_org_uuid(&org_id, conn).await else { err!("Invalid client_id", format!("IP: {}.", ip.ip)) }; // Check API key. let client_secret = data.client_secret.as_ref().unwrap(); if !org_api_key.check_valid_api_key(client_secret) { err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid)) } let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); let access_token = auth::encode_jwt(&claim); Ok(Json(json!({ "access_token": access_token, "expires_in": 3600, "token_type": "Bearer", "scope": AuthMethod::OrgApiKey.scope(), }))) } /// Retrieves an existing device or creates a new device from ConnectData and the User async fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> ApiResult { // On iOS, device_type sends "iOS", on others it sends a number // When unknown or unable to parse, return 14, which is 'Unknown Browser' let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14); let device_id = data.device_identifier.clone().expect("No device id provided"); let device_name = data.device_name.clone().expect("No device name provided"); // Find device or create new match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await { Some(device) => Ok(device), None => { let mut device = Device::new(device_id, user.uuid.clone(), device_name, device_type); // save device without updating `device.updated_at` device.save(false, conn).await?; Ok(device) } } } async fn twofactor_auth( user: &mut User, data: &ConnectData, device: &mut Device, ip: &ClientIp, client_version: &Option, conn: &DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; // No twofactor token if twofactor is disabled if twofactors.is_empty() { enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; return Ok(None); } TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?; let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect(); let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one let twofactor_code = match data.two_factor_token { Some(ref code) => code, None => { err_json!( _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, "2FA token not provided" ) } }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); use crate::crypto::ct_eq; let selected_data = _selected_data(selected_twofactor); let mut remember = data.two_factor_remember.unwrap_or(0); match TwoFactorType::from_i32(selected_id) { Some(TwoFactorType::Authenticator) => { authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await? } Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, Some(TwoFactorType::Duo) => { match CONFIG.duo_use_iframe() { true => { // Legacy iframe prompt flow duo::validate_duo_login(&user.email, twofactor_code, conn).await? } false => { // OIDC based flow duo_oidc::validate_duo_login( &user.email, twofactor_code, data.client_id.as_ref().unwrap(), data.device_identifier.as_ref().unwrap(), conn, ) .await? } } } Some(TwoFactorType::Email) => { email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await? } Some(TwoFactorType::Remember) => { match device.twofactor_remember { Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => { remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time } _ => { err_json!( _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, "2FA Remember token not provided" ) } } } Some(TwoFactorType::RecoveryCode) => { // Check if recovery code is correct if !user.check_valid_recovery_code(twofactor_code) { err!("Recovery code is incorrect. Try again.") } // Remove all twofactors from the user TwoFactor::delete_all_by_user(&user.uuid, conn).await?; enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; // Remove the recovery code, not needed without twofactors user.totp_recover = None; user.save(conn).await?; } _ => err!( "Invalid two factor provider", ErrorEvent { event: EventType::UserFailedLogIn2fa } ), } TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { Some(device.refresh_twofactor_remember()) } else { device.delete_twofactor_remember(); None }; Ok(two_factor) } fn _selected_data(tf: Option) -> ApiResult { tf.map(|t| t.data).map_res("Two factor doesn't exist") } async fn _json_err_twofactor( providers: &[i32], user_id: &UserId, data: &ConnectData, client_version: &Option, conn: &DbConn, ) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::>(), "TwoFactorProviders2" : {}, // { "0" : null } "MasterPasswordPolicy": { "Object": "masterPasswordPolicy" } }); for provider in providers { result["TwoFactorProviders2"][provider.to_string()] = Value::Null; match TwoFactorType::from_i32(*provider) { Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => { let request = webauthn::generate_webauthn_login(user_id, conn).await?; result["TwoFactorProviders2"][provider.to_string()] = request.0; } Some(TwoFactorType::Duo) => { let email = match User::find_by_uuid(user_id, conn).await { Some(u) => u.email, None => err!("User does not exist"), }; match CONFIG.duo_use_iframe() { true => { // Legacy iframe prompt flow let (signature, host) = duo::generate_duo_signature(&email, conn).await?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "Host": host, "Signature": signature, }) } false => { // OIDC based flow let auth_url = duo_oidc::get_duo_auth_url( &email, data.client_id.as_ref().unwrap(), data.device_identifier.as_ref().unwrap(), conn, ) .await?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "AuthUrl": auth_url, }) } } } Some(tf_type @ TwoFactorType::YubiKey) => { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No YubiKey devices registered") }; let yubikey_metadata: yubikey::YubikeyMetadata = serde_json::from_str(&twofactor.data)?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "Nfc": yubikey_metadata.nfc, }) } Some(tf_type @ TwoFactorType::Email) => { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No twofactor email registered") }; // Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`. let disabled_send = if let Some(cv) = client_version { let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap(); ver_match.matches(&cv.0) } else { false }; // Send email immediately if email is the only 2FA option. if providers.len() == 1 && !disabled_send { email::send_token(user_id, conn).await? } let email_data = email::EmailTokenData::from_json(&twofactor.data)?; result["TwoFactorProviders2"][provider.to_string()] = json!({ "Email": email::obscure_email(&email_data.email), }) } _ => {} } } Ok(result) } #[post("/accounts/prelogin", data = "")] async fn prelogin(data: Json, conn: DbConn) -> Json { _prelogin(data, conn).await } #[post("/accounts/register", data = "")] async fn identity_register(data: Json, conn: DbConn) -> JsonResult { _register(data, false, conn).await } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RegisterVerificationData { email: String, name: Option, // receiveMarketingEmails: bool, } #[derive(rocket::Responder)] enum RegisterVerificationResponse { #[response(status = 204)] NoContent(()), Token(Json), } #[post("/accounts/register/send-verification-email", data = "")] async fn register_verification_email( data: Json, conn: DbConn, ) -> ApiResult { let data = data.into_inner(); // the registration can only continue if signup is allowed or there exists an invitation if !(CONFIG.is_signup_allowed(&data.email) || (!CONFIG.mail_enabled() && Invitation::find_by_mail(&data.email, &conn).await.is_some())) { err!("Registration not allowed or user already exists") } let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify(); let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail); let token = auth::encode_jwt(&token_claims); if should_send_mail { let user = User::find_by_mail(&data.email, &conn).await; if user.filter(|u| u.private_key.is_some()).is_some() { // There is still a timing side channel here in that the code // paths that send mail take noticeably longer than ones that don't. // Add a randomized sleep to mitigate this somewhat. use rand::{rngs::SmallRng, RngExt}; let mut rng: SmallRng = rand::make_rng(); let sleep_ms = rng.random_range(900..=1100) as u64; tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await; } else { mail::send_register_verify_email(&data.email, &token).await?; } Ok(RegisterVerificationResponse::NoContent(())) } else { // If email verification is not required, return the token directly // the clients will use this token to finish the registration Ok(RegisterVerificationResponse::Token(Json(token))) } } #[post("/accounts/register/finish", data = "")] async fn register_finish(data: Json, conn: DbConn) -> JsonResult { _register(data, true, conn).await } // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs #[derive(Debug, Clone, Default, FromForm)] struct ConnectData { #[field(name = uncased("grant_type"))] #[field(name = uncased("granttype"))] grant_type: String, // refresh_token, password, client_credentials (API key) // Needed for grant_type="refresh_token" #[field(name = uncased("refresh_token"))] #[field(name = uncased("refreshtoken"))] refresh_token: Option, // Needed for grant_type = "password" | "client_credentials" #[field(name = uncased("client_id"))] #[field(name = uncased("clientid"))] client_id: Option, // web, cli, desktop, browser, mobile #[field(name = uncased("client_secret"))] #[field(name = uncased("clientsecret"))] client_secret: Option, #[field(name = uncased("password"))] password: Option, #[field(name = uncased("scope"))] scope: Option, #[field(name = uncased("username"))] username: Option, #[field(name = uncased("device_identifier"))] #[field(name = uncased("deviceidentifier"))] device_identifier: Option, #[field(name = uncased("device_name"))] #[field(name = uncased("devicename"))] device_name: Option, #[field(name = uncased("device_type"))] #[field(name = uncased("devicetype"))] device_type: Option, #[allow(unused)] #[field(name = uncased("device_push_token"))] #[field(name = uncased("devicepushtoken"))] _device_push_token: Option, // Unused; mobile device push not yet supported. // Needed for two-factor auth #[field(name = uncased("two_factor_provider"))] #[field(name = uncased("twofactorprovider"))] two_factor_provider: Option, #[field(name = uncased("two_factor_token"))] #[field(name = uncased("twofactortoken"))] two_factor_token: Option, #[field(name = uncased("two_factor_remember"))] #[field(name = uncased("twofactorremember"))] two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, // Needed for authorization code #[field(name = uncased("code"))] code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, } fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } #[get("/sso/prevalidate")] fn prevalidate() -> JsonResult { if CONFIG.sso_enabled() { let sso_token = sso::encode_ssotoken_claims(); Ok(Json(json!({ "token": sso_token, }))) } else { err!("SSO sign-in is not available") } } #[get("/connect/oidc-signin?&", rank = 1)] async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult { _oidcsignin_redirect( state, OIDCCodeWrapper::Ok { code, }, &mut conn, ) .await } // Bitwarden client appear to only care for code and state so we pipe it through // cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141 #[get("/connect/oidc-signin?&&", rank = 2)] async fn oidcsignin_error( state: String, error: String, error_description: Option, mut conn: DbConn, ) -> ApiResult { _oidcsignin_redirect( state, OIDCCodeWrapper::Error { error, error_description, }, &mut conn, ) .await } // The state was encoded using Base64 to ensure no issue with providers. // iss and scope parameters are needed for redirection to work on IOS. // We pass the state as the code to get it back later on. async fn _oidcsignin_redirect( base64_state: String, code_response: OIDCCodeWrapper, conn: &mut DbConn, ) -> ApiResult { let state = sso::decode_state(&base64_state)?; let mut sso_auth = match SsoAuth::find(&state, conn).await { None => err!(format!("Cannot retrieve sso_auth for {state}")), Some(sso_auth) => sso_auth, }; sso_auth.code_response = Some(code_response); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; let mut url = match url::Url::parse(&sso_auth.redirect_uri) { Ok(url) => url, Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", sso_auth.redirect_uri)), }; url.query_pairs_mut() .append_pair("code", &state) .append_pair("state", &state) .append_pair("scope", &AuthMethod::Sso.scope()) .append_pair("iss", &CONFIG.domain()); debug!("Redirection to {url}"); Ok(Redirect::temporary(String::from(url))) } #[derive(Debug, Clone, Default, FromForm)] struct AuthorizeData { #[field(name = uncased("client_id"))] #[field(name = uncased("clientid"))] client_id: String, #[field(name = uncased("redirect_uri"))] #[field(name = uncased("redirecturi"))] redirect_uri: String, #[allow(unused)] response_type: Option, #[allow(unused)] scope: Option, state: OIDCState, code_challenge: OIDCCodeChallenge, code_challenge_method: String, #[allow(unused)] response_mode: Option, #[allow(unused)] domain_hint: Option, #[allow(unused)] #[field(name = uncased("ssoToken"))] sso_token: Option, } // The `redirect_uri` will change depending of the client (web, android, ios ..) #[get("/connect/authorize?")] async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult { let AuthorizeData { client_id, redirect_uri, state, code_challenge, code_challenge_method, .. } = data; if code_challenge_method != "S256" { err!("Unsupported code challenge method"); } let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?; Ok(Redirect::temporary(String::from(auth_url))) } ================================================ FILE: src/api/mod.rs ================================================ mod admin; pub mod core; mod icons; mod identity; mod notifications; mod push; mod web; use rocket::serde::json::Json; use serde_json::Value; pub use crate::api::{ admin::catchers as admin_catchers, admin::routes as admin_routes, core::catchers as core_catchers, core::purge_auth_requests, core::purge_sends, core::purge_trashed_ciphers, core::routes as core_routes, core::two_factor::send_incomplete_2fa_notifications, core::{emergency_notification_reminder_job, emergency_request_timeout_job}, core::{event_cleanup_job, events_routes as core_events_routes}, icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, unregister_push_device, }, web::catchers as web_catchers, web::routes as web_routes, web::static_files, }; use crate::db::{ models::{OrgPolicy, OrgPolicyType, User}, DbConn, }; use crate::CONFIG; // Type aliases for API methods results pub type ApiResult = Result; pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] master_password_hash: Option, otp: Option, } impl PasswordOrOtpData { /// Tokens used via this struct can be used multiple times during the process /// First for the validation to continue, after that to enable or validate the following actions /// This is different per caller, so it can be adjusted to delete the token or not pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult { use crate::api::core::two_factor::protected_actions::validate_protected_action_otp; match (self.master_password_hash.as_deref(), self.otp.as_deref()) { (Some(pw_hash), None) => { if !user.check_valid_password(pw_hash) { err!("Invalid password"); } } (None, Some(otp)) => { validate_protected_action_otp(otp, &user.uuid, delete_if_valid, conn).await?; } _ => err!("No validation provided"), } Ok(()) } } #[derive(Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MasterPasswordPolicy { min_complexity: Option, min_length: Option, require_lower: bool, require_upper: bool, require_numbers: bool, require_special: bool, enforce_on_login: bool, } // Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy async fn master_password_policy(user: &User, conn: &DbConn) -> Value { let master_password_policies: Vec = OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( &user.uuid, OrgPolicyType::MasterPassword, conn, ) .await .into_iter() .filter_map(|p| serde_json::from_str(&p.data).ok()) .collect(); let mut mpp_json = if !master_password_policies.is_empty() { json!(master_password_policies.into_iter().reduce(|acc, policy| { MasterPasswordPolicy { min_complexity: acc.min_complexity.max(policy.min_complexity), min_length: acc.min_length.max(policy.min_length), require_lower: acc.require_lower || policy.require_lower, require_upper: acc.require_upper || policy.require_upper, require_numbers: acc.require_numbers || policy.require_numbers, require_special: acc.require_special || policy.require_special, enforce_on_login: acc.enforce_on_login || policy.enforce_on_login, } })) } else if CONFIG.sso_enabled() { CONFIG.sso_master_password_policy_value().unwrap_or(json!({})) } else { json!({}) }; // NOTE: Upstream still uses PascalCase here for `Object`! mpp_json["Object"] = json!("masterPasswordPolicy"); mpp_json } ================================================ FILE: src/api/notifications.rs ================================================ use std::{ net::IpAddr, sync::{Arc, LazyLock}, time::Duration, }; use chrono::{NaiveDateTime, Utc}; use rmpv::Value; use rocket::{futures::StreamExt, Route}; use rocket_ws::{Message, WebSocket}; use tokio::sync::mpsc::Sender; use crate::{ auth::{ClientIp, WsAccessTokenHeader}, db::{ models::{AuthRequestId, Cipher, CollectionId, Device, DeviceId, Folder, PushId, Send as DbSend, User, UserId}, DbConn, }, Error, CONFIG, }; pub static WS_USERS: LazyLock> = LazyLock::new(|| { Arc::new(WebSocketUsers { map: Arc::new(dashmap::DashMap::new()), }) }); pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock> = LazyLock::new(|| { Arc::new(AnonymousWebSocketSubscriptions { map: Arc::new(dashmap::DashMap::new()), }) }); use super::{ push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, }; static NOTIFICATIONS_DISABLED: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); pub fn routes() -> Vec { if CONFIG.enable_websocket() { routes![websockets_hub, anonymous_websockets_hub] } else { info!("WebSocket are disabled, realtime sync functionality will not work!"); routes![] } } #[derive(FromForm, Debug)] struct WsAccessToken { access_token: Option, } struct WSEntryMapGuard { users: Arc, user_uuid: UserId, entry_uuid: uuid::Uuid, addr: IpAddr, } impl WSEntryMapGuard { fn new(users: Arc, user_uuid: UserId, entry_uuid: uuid::Uuid, addr: IpAddr) -> Self { Self { users, user_uuid, entry_uuid, addr, } } } impl Drop for WSEntryMapGuard { fn drop(&mut self) { info!("Closing WS connection from {}", self.addr); if let Some(mut entry) = self.users.map.get_mut(self.user_uuid.as_ref()) { entry.retain(|(uuid, _)| uuid != &self.entry_uuid); } } } struct WSAnonymousEntryMapGuard { subscriptions: Arc, token: String, addr: IpAddr, } impl WSAnonymousEntryMapGuard { fn new(subscriptions: Arc, token: String, addr: IpAddr) -> Self { Self { subscriptions, token, addr, } } } impl Drop for WSAnonymousEntryMapGuard { fn drop(&mut self) { info!("Closing WS connection from {}", self.addr); self.subscriptions.map.remove(&self.token); } } #[allow(tail_expr_drop_order)] #[get("/hub?")] fn websockets_hub<'r>( ws: WebSocket, data: WsAccessToken, ip: ClientIp, header_token: WsAccessTokenHeader, ) -> Result { info!("Accepting Rocket WS connection from {}", ip.ip); let token = if let Some(token) = data.access_token { token } else if let Some(token) = header_token.access_token { token } else { err_code!("Invalid claim", 401) }; let Ok(claims) = crate::auth::decode_login(&token) else { err_code!("Invalid token", 401) }; let (mut rx, guard) = { let users = Arc::clone(&WS_USERS); // Add a channel to send messages to this client to the map let entry_uuid = uuid::Uuid::new_v4(); let (tx, rx) = tokio::sync::mpsc::channel::(100); users.map.entry(claims.sub.to_string()).or_default().push((entry_uuid, tx)); // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map (rx, WSEntryMapGuard::new(users, claims.sub, entry_uuid, ip.ip)) }; Ok({ rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; let mut interval = tokio::time::interval(Duration::from_secs(15)); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), Message::Pong(_) => {/* Ignored */}, // We should receive an initial message with the protocol and version, and we will reply to it Message::Text(ref message) => { let msg = message.strip_suffix(RECORD_SEPARATOR as char).unwrap_or(message); if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); } } // Prevent sending anything back when a `Close` Message is received. // Just break the loop Message::Close(_) => break, // Just echo anything else the client sends _ => yield message, } } _ => break, } } res = rx.recv() => { match res { Some(res) => yield res, None => break, } } _ = interval.tick() => yield Message::Ping(create_ping()) } } }} }) } #[allow(tail_expr_drop_order)] #[get("/anonymous-hub?")] fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> Result { info!("Accepting Anonymous Rocket WS connection from {}", ip.ip); let (mut rx, guard) = { let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); // Add a channel to send messages to this client to the map let (tx, rx) = tokio::sync::mpsc::channel::(100); subscriptions.map.insert(token.clone(), tx); // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip)) }; Ok({ rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; let mut interval = tokio::time::interval(Duration::from_secs(15)); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), Message::Pong(_) => {/* Ignored */}, // We should receive an initial message with the protocol and version, and we will reply to it Message::Text(ref message) => { let msg = message.strip_suffix(RECORD_SEPARATOR as char).unwrap_or(message); if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { yield Message::binary(INITIAL_RESPONSE); } } // Prevent sending anything back when a `Close` Message is received. // Just break the loop Message::Close(_) => break, // Just echo anything else the client sends _ => yield message, } } _ => break, } } res = rx.recv() => { match res { Some(res) => yield res, None => break, } } _ = interval.tick() => yield Message::Ping(create_ping()) } } }} }) } // // Websockets server // fn serialize(val: &Value) -> Vec { use rmpv::encode::write_value; let mut buf = Vec::new(); write_value(&mut buf, val).expect("Error encoding MsgPack"); // Add size bytes at the start // Extracted from BinaryMessageFormat.js let mut size: usize = buf.len(); let mut len_buf: Vec = Vec::new(); loop { let mut size_part = size & 0x7f; size >>= 7; if size > 0 { size_part |= 0x80; } len_buf.push(size_part as u8); if size == 0 { break; } } len_buf.append(&mut buf); len_buf } fn serialize_date(date: NaiveDateTime) -> Value { let seconds: i64 = date.and_utc().timestamp(); let nanos: i64 = date.and_utc().timestamp_subsec_nanos().into(); let timestamp = (nanos << 34) | seconds; let bs = timestamp.to_be_bytes(); // -1 is Timestamp // https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type Value::Ext(-1, bs.to_vec()) } fn convert_option>(option: Option) -> Value { match option { Some(a) => a.into(), None => Value::Nil, } } const RECORD_SEPARATOR: u8 = 0x1e; const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, #[derive(Deserialize, Copy, Clone, Eq, PartialEq)] struct InitialMessage<'a> { protocol: &'a str, version: i32, } static INITIAL_MESSAGE: InitialMessage<'static> = InitialMessage { protocol: "messagepack", version: 1, }; // We attach the UUID to the sender so we can differentiate them when we need to remove them from the Vec type UserSenders = (uuid::Uuid, Sender); #[derive(Clone)] pub struct WebSocketUsers { map: Arc>>, } impl WebSocketUsers { async fn send_update(&self, user_id: &UserId, data: &[u8]) { if let Some(user) = self.map.get(user_id.as_ref()).map(|v| v.clone()) { for (_, sender) in user.iter() { if let Err(e) = sender.send(Message::binary(data)).await { error!("Error sending WS update {e}"); } } } } // NOTE: The last modified date needs to be updated before calling these methods pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], ut, None, ); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { push_user_update(ut, user, push_uuid, conn).await; } } pub async fn send_logout(&self, user: &User, acting_device_id: Option, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], UpdateType::LogOut, acting_device_id.clone(), ); if CONFIG.enable_websocket() { self.send_update(&user.uuid, &data).await; } if CONFIG.push_enabled() { push_logout(user, acting_device_id.clone(), conn).await; } } pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![ ("Id".into(), folder.uuid.to_string().into()), ("UserId".into(), folder.user_uuid.to_string().into()), ("RevisionDate".into(), serialize_date(folder.updated_at)), ], ut, Some(device.uuid.clone()), ); if CONFIG.enable_websocket() { self.send_update(&folder.user_uuid, &data).await; } if CONFIG.push_enabled() { push_folder_update(ut, folder, device, conn).await; } } pub async fn send_cipher_update( &self, ut: UpdateType, cipher: &Cipher, user_ids: &[UserId], device: &Device, collection_uuids: Option>, conn: &DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let org_id = convert_option(cipher.organization_uuid.as_deref()); // Depending if there are collections provided or not, we need to have different values for the following variables. // The user_uuid should be `null`, and the revision date should be set to now, else the clients won't sync the collection change. let (user_id, collection_uuids, revision_date) = if let Some(collection_uuids) = collection_uuids { ( Value::Nil, Value::Array(collection_uuids.into_iter().map(|v| v.to_string().into()).collect::>()), serialize_date(Utc::now().naive_utc()), ) } else { (convert_option(cipher.user_uuid.as_deref()), Value::Nil, serialize_date(cipher.updated_at)) }; let data = create_update( vec![ ("Id".into(), cipher.uuid.to_string().into()), ("UserId".into(), user_id), ("OrganizationId".into(), org_id), ("CollectionIds".into(), collection_uuids), ("RevisionDate".into(), revision_date), ], ut, Some(device.uuid.clone()), // Acting device id (unique device/app uuid) ); if CONFIG.enable_websocket() { for uuid in user_ids { self.send_update(uuid, &data).await; } } if CONFIG.push_enabled() && user_ids.len() == 1 { push_cipher_update(ut, cipher, device, conn).await; } } pub async fn send_send_update( &self, ut: UpdateType, send: &DbSend, user_ids: &[UserId], device: &Device, conn: &DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let user_id = convert_option(send.user_uuid.as_deref()); let data = create_update( vec![ ("Id".into(), send.uuid.to_string().into()), ("UserId".into(), user_id), ("RevisionDate".into(), serialize_date(send.revision_date)), ], ut, None, ); if CONFIG.enable_websocket() { for uuid in user_ids { self.send_update(uuid, &data).await; } } if CONFIG.push_enabled() && user_ids.len() == 1 { push_send_update(ut, send, device, conn).await; } } pub async fn send_auth_request(&self, user_id: &UserId, auth_request_uuid: &str, device: &Device, conn: &DbConn) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![("Id".into(), auth_request_uuid.to_owned().into()), ("UserId".into(), user_id.to_string().into())], UpdateType::AuthRequest, Some(device.uuid.clone()), ); if CONFIG.enable_websocket() { self.send_update(user_id, &data).await; } if CONFIG.push_enabled() { push_auth_request(user_id, auth_request_uuid, device, conn).await; } } pub async fn send_auth_response( &self, user_id: &UserId, auth_request_id: &AuthRequestId, device: &Device, conn: &DbConn, ) { // Skip any processing if both WebSockets and Push are not active if *NOTIFICATIONS_DISABLED { return; } let data = create_update( vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], UpdateType::AuthRequestResponse, Some(device.uuid.clone()), ); if CONFIG.enable_websocket() { self.send_update(user_id, &data).await; } if CONFIG.push_enabled() { push_auth_response(user_id, auth_request_id, device, conn).await; } } } #[derive(Clone)] pub struct AnonymousWebSocketSubscriptions { map: Arc>>, } impl AnonymousWebSocketSubscriptions { async fn send_update(&self, token: &str, data: &[u8]) { if let Some(sender) = self.map.get(token).map(|v| v.clone()) { if let Err(e) = sender.send(Message::binary(data)).await { error!("Error sending WS update {e}"); } } } pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) { if !CONFIG.enable_websocket() { return; } let data = create_anonymous_update( vec![("Id".into(), auth_request_id.to_string().into()), ("UserId".into(), user_id.to_string().into())], UpdateType::AuthRequestResponse, user_id, ); self.send_update(auth_request_id, &data).await; } } /* Message Structure [ 1, // MessageType.Invocation {}, // Headers (map) null, // InvocationId "ReceiveMessage", // Target [ // Arguments { "ContextId": acting_device_id || Nil, "Type": ut as i32, "Payload": {} } ] ] */ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_id: Option) -> Vec { use rmpv::Value as V; let value = V::Array(vec![ 1.into(), V::Map(vec![]), V::Nil, "ReceiveMessage".into(), V::Array(vec![V::Map(vec![ ("ContextId".into(), acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| V::Nil)), ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), ])]), ]); serialize(&value) } fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: &UserId) -> Vec { use rmpv::Value as V; let value = V::Array(vec![ 1.into(), V::Map(vec![]), V::Nil, // This word is misspelled, but upstream has this too // https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 // https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 "AuthRequestResponseRecieved".into(), V::Array(vec![V::Map(vec![ ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), ("UserId".into(), user_id.to_string().into()), ])]), ]); serialize(&value) } fn create_ping() -> Vec { serialize(&Value::Array(vec![6.into()])) } // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs #[derive(Copy, Clone, Eq, PartialEq)] pub enum UpdateType { SyncCipherUpdate = 0, SyncCipherCreate = 1, SyncLoginDelete = 2, SyncFolderDelete = 3, SyncCiphers = 4, SyncVault = 5, SyncOrgKeys = 6, SyncFolderCreate = 7, SyncFolderUpdate = 8, // SyncCipherDelete = 9, // Redirects to `SyncLoginDelete` on upstream SyncSettings = 10, LogOut = 11, SyncSendCreate = 12, SyncSendUpdate = 13, SyncSendDelete = 14, AuthRequest = 15, AuthRequestResponse = 16, // SyncOrganizations = 17, // Not supported // SyncOrganizationStatusChanged = 18, // Not supported // SyncOrganizationCollectionSettingChanged = 19, // Not supported // Notification = 20, // Not supported // NotificationStatus = 21, // Not supported // RefreshSecurityTasks = 22, // Not supported None = 100, } pub type Notify<'a> = &'a rocket::State>; pub type AnonymousNotify<'a> = &'a rocket::State>; ================================================ FILE: src/api/push.rs ================================================ use std::{ sync::LazyLock, time::{Duration, Instant}, }; use reqwest::{ header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, Method, }; use serde_json::Value; use tokio::sync::RwLock; use crate::{ api::{ApiResult, EmptyResult, UpdateType}, db::{ models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId}, DbConn, }, http_client::make_http_request, util::{format_date, get_uuid}, CONFIG, }; #[derive(Deserialize)] struct AuthPushToken { access_token: String, expires_in: i32, } #[derive(Debug)] struct LocalAuthPushToken { access_token: String, valid_until: Instant, } async fn get_auth_api_token() -> ApiResult { static API_TOKEN: LazyLock> = LazyLock::new(|| { RwLock::new(LocalAuthPushToken { access_token: String::new(), valid_until: Instant::now(), }) }); let api_token = API_TOKEN.read().await; if api_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { debug!("Auth Push token still valid, no need for a new one"); return Ok(api_token.access_token.clone()); } drop(api_token); // Drop the read lock now let installation_id = CONFIG.push_installation_id(); let client_id = format!("installation.{installation_id}"); let client_secret = CONFIG.push_installation_key(); let params = [ ("grant_type", "client_credentials"), ("scope", "api.push"), ("client_id", &client_id), ("client_secret", &client_secret), ]; let res = match make_http_request(Method::POST, &format!("{}/connect/token", CONFIG.push_identity_uri()))? .form(¶ms) .send() .await { Ok(r) => r, Err(e) => err!(format!("Error getting push token from bitwarden server: {e}")), }; let json_pushtoken = match res.json::().await { Ok(r) => r, Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")), }; let mut api_token = API_TOKEN.write().await; api_token.valid_until = Instant::now() .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time .unwrap(); api_token.access_token = json_pushtoken.access_token; debug!("Token still valid for {}", api_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); Ok(api_token.access_token.clone()) } pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyResult { if !CONFIG.push_enabled() || !device.is_push_device() { return Ok(()); } if device.push_token.is_none() { warn!("Skipping the registration of the device {:?} because the push_token field is empty.", device.uuid); warn!("To get rid of this message you need to logout, clear the app data and login again on the device."); return Ok(()); } debug!("Registering Device {:?}", device.push_uuid); // Generate a random push_uuid so if it doesn't already have one if device.push_uuid.is_none() { device.push_uuid = Some(PushId(get_uuid())); } //Needed to register a device for push to bitwarden : let data = json!({ "deviceId": device.push_uuid, // Unique UUID per user/device "pushToken": device.push_token, "userId": device.user_uuid, "type": device.atype, "identifier": device.uuid, // Unique UUID of the device/app, determined by the device/app it self currently registering // "organizationIds:" [] // TODO: This is not yet implemented by Vaultwarden! "installationId": CONFIG.push_installation_id(), }); let auth_api_token = get_auth_api_token().await?; let auth_header = format!("Bearer {auth_api_token}"); if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/register"))? .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") .header(AUTHORIZATION, auth_header) .json(&data) .send() .await? .error_for_status() { err!(format!("An error occurred while proceeding registration of a device: {e}")); } if let Err(e) = device.save(true, conn).await { err!(format!("An error occurred while trying to save the (registered) device push uuid: {e}")); } Ok(()) } pub async fn unregister_push_device(push_id: &Option) -> EmptyResult { if !CONFIG.push_enabled() || push_id.is_none() { return Ok(()); } let auth_api_token = get_auth_api_token().await?; let auth_header = format!("Bearer {auth_api_token}"); match make_http_request( Method::POST, &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), )? .header(AUTHORIZATION, auth_header) .send() .await { Ok(r) => r, Err(e) => err!(format!("An error occurred during device unregistration: {e}")), }; Ok(()) } pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device, conn: &DbConn) { // We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too. if cipher.organization_uuid.is_some() { return; }; let Some(user_id) = &cipher.user_uuid else { debug!("Cipher has no uuid"); return; }; if Device::check_user_has_push_device(user_id, conn).await { send_to_push_relay(json!({ "userId": user_id, "organizationId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "type": ut as i32, "payload": { "id": cipher.uuid, "userId": cipher.user_uuid, "organizationId": null, "collectionIds": null, "revisionDate": format_date(&cipher.updated_at) }, "clientType": null, "installationId": null })) .await; } } pub async fn push_logout(user: &User, acting_device_id: Option, conn: &DbConn) { let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null); if Device::check_user_has_push_device(&user.uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, "organizationId": (), "deviceId": acting_device_id, "identifier": acting_device_id, "type": UpdateType::LogOut as i32, "payload": { "userId": user.uuid, "date": format_date(&user.updated_at) }, "clientType": null, "installationId": null }))); } } pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user.uuid, "organizationId": null, "deviceId": push_uuid, "identifier": null, "type": ut as i32, "payload": { "userId": user.uuid, "date": format_date(&user.updated_at) }, "clientType": null, "installationId": null }))); } } pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device, conn: &DbConn) { if Device::check_user_has_push_device(&folder.user_uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": folder.user_uuid, "organizationId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "type": ut as i32, "payload": { "id": folder.uuid, "userId": folder.user_uuid, "revisionDate": format_date(&folder.updated_at) }, "clientType": null, "installationId": null }))); } } pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) { if let Some(s) = &send.user_uuid { if Device::check_user_has_push_device(s, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": send.user_uuid, "organizationId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "type": ut as i32, "payload": { "id": send.uuid, "userId": send.user_uuid, "revisionDate": format_date(&send.revision_date) }, "clientType": null, "installationId": null }))); } } } async fn send_to_push_relay(notification_data: Value) { if !CONFIG.push_enabled() { return; } let auth_api_token = match get_auth_api_token().await { Ok(s) => s, Err(e) => { debug!("Could not get the auth push token: {e}"); return; } }; let auth_header = format!("Bearer {auth_api_token}"); let req = match make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/send")) { Ok(r) => r, Err(e) => { error!("An error occurred while sending a send update to the push relay: {e}"); return; } }; if let Err(e) = req .header(ACCEPT, "application/json") .header(CONTENT_TYPE, "application/json") .header(AUTHORIZATION, &auth_header) .json(¬ification_data) .send() .await { error!("An error occurred while sending a send update to the push relay: {e}"); }; } pub async fn push_auth_request(user_id: &UserId, auth_request_id: &str, device: &Device, conn: &DbConn) { if Device::check_user_has_push_device(user_id, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user_id, "organizationId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "type": UpdateType::AuthRequest as i32, "payload": { "userId": user_id, "id": auth_request_id, }, "clientType": null, "installationId": null }))); } } pub async fn push_auth_response(user_id: &UserId, auth_request_id: &AuthRequestId, device: &Device, conn: &DbConn) { if Device::check_user_has_push_device(user_id, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": user_id, "organizationId": null, "deviceId": device.push_uuid, // Should be the records unique uuid of the acting device (unique uuid per user/device) "identifier": device.uuid, // Should be the acting device id (aka uuid per device/app) "type": UpdateType::AuthRequestResponse as i32, "payload": { "userId": user_id, "id": auth_request_id, }, "clientType": null, "installationId": null }))); } } ================================================ FILE: src/api/web.rs ================================================ use std::path::{Path, PathBuf}; use rocket::{ fs::NamedFile, http::ContentType, response::{content::RawCss as Css, content::RawHtml as Html, Redirect}, serde::json::Json, Catcher, Route, }; use serde_json::Value; use crate::{ api::{core::now, ApiResult, EmptyResult}, auth::decode_file_download, db::models::{AttachmentId, CipherId}, error::Error, util::Cached, CONFIG, }; pub fn routes() -> Vec { // If adding more routes here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log let mut routes = routes![attachments, alive, alive_head, static_files]; if CONFIG.web_vault_enabled() { routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]); } #[cfg(debug_assertions)] if CONFIG.reload_templates() { routes.append(&mut routes![_static_files_dev]); } routes } pub fn catchers() -> Vec { if CONFIG.web_vault_enabled() { catchers![not_found] } else { catchers![] } } #[catch(404)] fn not_found() -> ApiResult> { // Return the page let json = json!({ "urlpath": CONFIG.domain_path() }); let text = CONFIG.render_template("404", &json)?; Ok(Html(text)) } #[get("/css/vaultwarden.css")] fn vaultwarden_css() -> Cached> { let css_options = json!({ "emergency_access_allowed": CONFIG.emergency_access_allowed(), "load_user_scss": true, "mail_2fa_enabled": CONFIG._enable_email_2fa(), "mail_enabled": CONFIG.mail_enabled(), "sends_allowed": CONFIG.sends_allowed(), "remember_2fa_disabled": CONFIG.disable_2fa_remember(), "password_hints_allowed": CONFIG.password_hints_allowed(), "signup_disabled": CONFIG.is_signup_disabled(), "sso_enabled": CONFIG.sso_enabled(), "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), "webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(), "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), }); let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { Ok(t) => t, Err(e) => { // Something went wrong loading the template. Use the fallback warn!("Loading scss/vaultwarden.scss.hbs or scss/user.vaultwarden.scss.hbs failed. {e}"); CONFIG .render_fallback_template("scss/vaultwarden.scss", &css_options) .expect("Fallback scss/vaultwarden.scss.hbs to render") } }; let css = match grass_compiler::from_string( scss, &grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed), ) { Ok(css) => css, Err(e) => { // Something went wrong compiling the scss. Use the fallback warn!("Compiling the Vaultwarden SCSS styles failed. {e}"); let mut css_options = css_options; css_options["load_user_scss"] = json!(false); let scss = CONFIG .render_fallback_template("scss/vaultwarden.scss", &css_options) .expect("Fallback scss/vaultwarden.scss.hbs to render"); grass_compiler::from_string( scss, &grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed), ) .expect("SCSS to compile") } }; // Cache for one day should be enough and not too much Cached::ttl(Css(css), 86_400, false) } #[get("/")] async fn web_index() -> Cached> { Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false) } // Make sure that `/index.html` redirect to actual domain path. // If not, this might cause issues with the web-vault #[get("/index.html")] fn web_index_direct() -> Redirect { Redirect::to(format!("{}/", CONFIG.domain_path())) } #[head("/")] fn web_index_head() -> EmptyResult { // Add an explicit HEAD route to prevent uptime monitoring services from // generating "No matching routes for HEAD /" error messages. // // Rocket automatically implements a HEAD route when there's a matching GET // route, but relying on this behavior also means a spurious error gets // logged due to . Ok(()) } #[get("/app-id.json")] fn app_id() -> Cached<(ContentType, Json)> { let content_type = ContentType::new("application", "fido.trusted-apps+json"); Cached::long( ( content_type, Json(json!({ "trustedFacets": [ { "version": { "major": 1, "minor": 0 }, "ids": [ // Per : // // "In the Web case, the FacetID MUST be the Web Origin [RFC6454] // of the web page triggering the FIDO operation, written as // a URI with an empty path. Default ports are omitted and any // path component is ignored." // // This leaves it unclear as to whether the path must be empty, // or whether it can be non-empty and will be ignored. To be on // the safe side, use a proper web origin (with empty path). &CONFIG.domain_origin(), "ios:bundle-id:com.8bit.bitwarden", "android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ] }] })), ), true, ) } #[get("/", rank = 10)] // Only match this if the other routes don't match async fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join(p)).await.ok(), true) } #[get("/attachments//?")] async fn attachments(cipher_id: CipherId, file_id: AttachmentId, token: String) -> Option { let Ok(claims) = decode_file_download(&token) else { return None; }; if claims.sub != cipher_id || claims.file_id != file_id { return None; } NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(cipher_id.as_ref()).join(file_id.as_ref())).await.ok() } // We use DbConn here to let the alive healthcheck also verify the database connection. use crate::db::DbConn; #[get("/alive")] fn alive(_conn: DbConn) -> Json { now() } #[head("/alive")] fn alive_head(_conn: DbConn) -> EmptyResult { // Avoid logging spurious "No matching routes for HEAD /alive" errors // due to . Ok(()) } // This endpoint/function is used during development and development only. // It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes // This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true` // NOTE: Do not forget to add any new files added to the `static_files` function below! #[cfg(debug_assertions)] #[get("/vw_static/", rank = 1)] pub async fn _static_files_dev(filename: PathBuf) -> Option { warn!("LOADING STATIC FILES FROM DISK"); let file = filename.to_str().unwrap_or_default(); let ext = filename.extension().unwrap_or_default(); let path = if ext == "png" || ext == "svg" { tokio::fs::canonicalize(Path::new(file!()).parent().unwrap().join("../static/images/").join(file)).await } else { tokio::fs::canonicalize(Path::new(file!()).parent().unwrap().join("../static/scripts/").join(file)).await }; if let Ok(path) = path { return NamedFile::open(path).await.ok(); }; None } #[get("/vw_static/", rank = 2)] pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> { match filename { "404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))), "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), "error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), "admin_organizations.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) } "admin_diagnostics.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) } "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), "bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))), "jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))), "datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), "datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), "jquery-4.0.0.slim.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js"))) } _ => err!(format!("Static file not found: {filename}")), } } ================================================ FILE: src/auth.rs ================================================ use std::{ env, net::IpAddr, sync::{LazyLock, OnceLock}, }; use chrono::{DateTime, TimeDelta, Utc}; use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; use serde::de::DeserializeOwned; use serde::ser::Serialize; use crate::{ api::ApiResult, config::PathType, db::models::{ AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId, SendFileId, SendId, UserId, }, error::Error, sso, CONFIG, }; const JWT_ALGORITHM: Algorithm = Algorithm::RS256; // Limit when BitWarden consider the token as expired pub static BW_EXPIRATION: LazyLock = LazyLock::new(|| TimeDelta::try_minutes(5).unwrap()); pub static DEFAULT_REFRESH_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_days(30).unwrap()); pub static MOBILE_REFRESH_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_days(90).unwrap()); pub static DEFAULT_ACCESS_VALIDITY: LazyLock = LazyLock::new(|| TimeDelta::try_hours(2).unwrap()); static JWT_HEADER: LazyLock
= LazyLock::new(|| Header::new(JWT_ALGORITHM)); pub static JWT_LOGIN_ISSUER: LazyLock = LazyLock::new(|| format!("{}|login", CONFIG.domain_origin())); static JWT_INVITE_ISSUER: LazyLock = LazyLock::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: LazyLock = LazyLock::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: LazyLock = LazyLock::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: LazyLock = LazyLock::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: LazyLock = LazyLock::new(|| format!("{}|admin", CONFIG.domain_origin())); static JWT_SEND_ISSUER: LazyLock = LazyLock::new(|| format!("{}|send", CONFIG.domain_origin())); static JWT_ORG_API_KEY_ISSUER: LazyLock = LazyLock::new(|| format!("{}|api.organization", CONFIG.domain_origin())); static JWT_FILE_DOWNLOAD_ISSUER: LazyLock = LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin())); static JWT_REGISTER_VERIFY_ISSUER: LazyLock = LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin())); static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error; let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key()) .file_name() .ok_or_else(|| Error::other("Private RSA key path missing filename"))? .to_str() .ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))? .to_string(); let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?; let priv_key_buffer = match operator.read(&rsa_key_filename).await { Ok(buffer) => Some(buffer), Err(e) if e.kind() == opendal::ErrorKind::NotFound => None, Err(e) => return Err(e.into()), }; let (priv_key, priv_key_buffer) = if let Some(priv_key_buffer) = priv_key_buffer { (Rsa::private_key_from_pem(priv_key_buffer.to_vec().as_slice())?, priv_key_buffer.to_vec()) } else { let rsa_key = Rsa::generate(2048)?; let priv_key_buffer = rsa_key.private_key_to_pem()?; operator.write(&rsa_key_filename, priv_key_buffer.clone()).await?; info!("Private key '{}' created correctly", CONFIG.private_rsa_key()); (rsa_key, priv_key_buffer) }; let pub_key_buffer = priv_key.public_key_to_pem()?; let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?; if PRIVATE_RSA_KEY.set(enc).is_err() { err!("PRIVATE_RSA_KEY must only be initialized once") } if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } Ok(()) } pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token, Err(e) => panic!("Error encoding jwt {e}"), } } pub fn decode_jwt(token: &str, issuer: String) -> Result { let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); validation.leeway = 30; // 30 seconds validation.validate_exp = true; validation.validate_nbf = true; validation.set_issuer(&[issuer]); let token = token.replace(char::is_whitespace, ""); match jsonwebtoken::decode(&token, PUBLIC_RSA_KEY.wait(), &validation) { Ok(d) => Ok(d.claims), Err(err) => match *err.kind() { ErrorKind::InvalidToken => err!("Token is invalid"), ErrorKind::InvalidIssuer => err!("Issuer is invalid"), ErrorKind::ExpiredSignature => err!("Token has expired"), _ => err!(format!("Error decoding JWT: {:?}", err)), }, } } pub fn decode_refresh(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } pub fn decode_login(token: &str) -> Result { decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) } pub fn decode_invite(token: &str) -> Result { decode_jwt(token, JWT_INVITE_ISSUER.to_string()) } pub fn decode_emergency_access_invite(token: &str) -> Result { decode_jwt(token, JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string()) } pub fn decode_delete(token: &str) -> Result { decode_jwt(token, JWT_DELETE_ISSUER.to_string()) } pub fn decode_verify_email(token: &str) -> Result { decode_jwt(token, JWT_VERIFYEMAIL_ISSUER.to_string()) } pub fn decode_admin(token: &str) -> Result { decode_jwt(token, JWT_ADMIN_ISSUER.to_string()) } pub fn decode_send(token: &str) -> Result { decode_jwt(token, JWT_SEND_ISSUER.to_string()) } pub fn decode_api_org(token: &str) -> Result { decode_jwt(token, JWT_ORG_API_KEY_ISSUER.to_string()) } pub fn decode_file_download(token: &str) -> Result { decode_jwt(token, JWT_FILE_DOWNLOAD_ISSUER.to_string()) } pub fn decode_register_verify(token: &str) -> Result { decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string()) } #[derive(Debug, Serialize, Deserialize)] pub struct LoginJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: UserId, pub premium: bool, pub name: String, pub email: String, pub email_verified: bool, // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // pub orgowner: Vec, // pub orgadmin: Vec, // pub orguser: Vec, // pub orgmanager: Vec, // user security_stamp pub sstamp: String, // device uuid pub device: DeviceId, // what kind of device, like FirefoxBrowser or Android derived from DeviceType pub devicetype: String, // the type of client_id, like web, cli, desktop, browser or mobile pub client_id: String, // [ "api", "offline_access" ] pub scope: Vec, // [ "Application" ] pub amr: Vec, } impl LoginJwtClaims { pub fn new( device: &Device, user: &User, nbf: i64, exp: i64, scope: Vec, client_id: Option, now: DateTime, ) -> Self { // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out // --- // fn arg: orgs: Vec, // --- // let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect(); // let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect(); // let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect(); // let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect(); if exp <= (now + *BW_EXPIRATION).timestamp() { warn!("Raise access_token lifetime to more than 5min.") } // Create the JWT claims struct, to send to the client Self { nbf, exp, iss: JWT_LOGIN_ISSUER.to_string(), sub: user.uuid.clone(), premium: true, name: user.name.clone(), email: user.email.clone(), email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(), // --- // Disabled these keys to be added to the JWT since they could cause the JWT to get too large // Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients // Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out // See: https://github.com/dani-garcia/vaultwarden/issues/4156 // --- // orgowner, // orgadmin, // orguser, // orgmanager, sstamp: user.security_stamp.clone(), device: device.uuid.clone(), devicetype: DeviceType::from_i32(device.atype).to_string(), client_id: client_id.unwrap_or("undefined".to_string()), scope, amr: vec!["Application".into()], } } pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option) -> Self { let time_now = Utc::now(); Self::new( device, user, time_now.timestamp(), (time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(), auth_method.scope_vec(), client_id, time_now, ) } pub fn token(&self) -> String { encode_jwt(&self) } pub fn expires_in(&self) -> i64 { self.exp - Utc::now().timestamp() } } #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: UserId, pub email: String, pub org_id: OrganizationId, pub member_id: MembershipId, pub invited_by_email: Option, } pub fn generate_invite_claims( user_id: UserId, email: String, org_id: OrganizationId, member_id: MembershipId, invited_by_email: Option, ) -> InviteJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); InviteJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_INVITE_ISSUER.to_string(), sub: user_id, email, org_id, member_id, invited_by_email, } } #[derive(Debug, Serialize, Deserialize)] pub struct EmergencyAccessInviteJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: UserId, pub email: String, pub emer_id: EmergencyAccessId, pub grantor_name: String, pub grantor_email: String, } pub fn generate_emergency_access_invite_claims( user_id: UserId, email: String, emer_id: EmergencyAccessId, grantor_name: String, grantor_email: String, ) -> EmergencyAccessInviteJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); EmergencyAccessInviteJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), sub: user_id, email, emer_id, grantor_name, grantor_email, } } #[derive(Debug, Serialize, Deserialize)] pub struct OrgApiKeyLoginJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: OrgApiKeyId, pub client_id: String, pub client_sub: OrganizationId, pub scope: Vec, } pub fn generate_organization_api_key_login_claims( org_api_key_uuid: OrgApiKeyId, org_id: OrganizationId, ) -> OrgApiKeyLoginJwtClaims { let time_now = Utc::now(); OrgApiKeyLoginJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(1).unwrap()).timestamp(), iss: JWT_ORG_API_KEY_ISSUER.to_string(), sub: org_api_key_uuid, client_id: format!("organization.{org_id}"), client_sub: org_id, scope: vec!["api.organization".into()], } } #[derive(Debug, Serialize, Deserialize)] pub struct FileDownloadClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: CipherId, pub file_id: AttachmentId, } pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId) -> FileDownloadClaims { let time_now = Utc::now(); FileDownloadClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(), sub: cipher_id, file_id, } } #[derive(Debug, Serialize, Deserialize)] pub struct RegisterVerifyClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: String, pub name: Option, pub verified: bool, } pub fn generate_register_verify_claims(email: String, name: Option, verified: bool) -> RegisterVerifyClaims { let time_now = Utc::now(); RegisterVerifyClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_minutes(30).unwrap()).timestamp(), iss: JWT_REGISTER_VERIFY_ISSUER.to_string(), sub: email, name, verified, } } #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: String, } pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); BasicJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_DELETE_ISSUER.to_string(), sub: uuid, } } pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); BasicJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(), sub: user_id.to_string(), } } pub fn generate_admin_claims() -> BasicJwtClaims { let time_now = Utc::now(); BasicJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), iss: JWT_ADMIN_ISSUER.to_string(), sub: "admin_panel".to_string(), } } pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { let time_now = Utc::now(); BasicJwtClaims { nbf: time_now.timestamp(), exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), iss: JWT_SEND_ISSUER.to_string(), sub: format!("{send_id}/{file_id}"), } } // // Bearer token authentication // use rocket::{ outcome::try_outcome, request::{FromRequest, Outcome, Request}, }; use crate::db::{ models::{Collection, Device, Membership, MembershipStatus, MembershipType, User, UserStampException}, DbConn, }; pub struct Host { pub host: String, } #[rocket::async_trait] impl<'r> FromRequest<'r> for Host { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); // Get host let host = if CONFIG.domain_set() { CONFIG.domain() } else if let Some(referer) = headers.get_one("Referer") { referer.to_string() } else { // Try to guess from the headers let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") { proto } else if env::var("ROCKET_TLS").is_ok() { "https" } else { "http" }; let host = if let Some(host) = headers.get_one("X-Forwarded-Host") { host } else { headers.get_one("Host").unwrap_or_default() }; format!("{protocol}://{host}") }; Outcome::Success(Host { host, }) } } pub struct ClientHeaders { pub device_type: i32, pub ip: ClientIp, } #[rocket::async_trait] impl<'r> FromRequest<'r> for ClientHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let ip = match ClientIp::from_request(request).await { Outcome::Success(ip) => ip, _ => err_handler!("Error getting Client IP"), }; // When unknown or unable to parse, return 14, which is 'Unknown Browser' let device_type: i32 = request.headers().get_one("device-type").map(|d| d.parse().unwrap_or(14)).unwrap_or_else(|| 14); Outcome::Success(ClientHeaders { device_type, ip, }) } } pub struct Headers { pub host: String, pub device: Device, pub user: User, pub ip: ClientIp, } #[rocket::async_trait] impl<'r> FromRequest<'r> for Headers { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); let host = try_outcome!(Host::from_request(request).await).host; let ip = match ClientIp::from_request(request).await { Outcome::Success(ip) => ip, _ => err_handler!("Error getting Client IP"), }; // Get access_token let access_token: &str = match headers.get_one("Authorization") { Some(a) => match a.rsplit("Bearer ").next() { Some(split) => split, None => err_handler!("No access token provided"), }, None => err_handler!("No access token provided"), }; // Check JWT token is valid and get device and user from it let Ok(claims) = decode_login(access_token) else { err_handler!("Invalid claim") }; let device_id = claims.device; let user_id = claims.sub; let conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; let Some(device) = Device::find_by_uuid_and_user(&device_id, &user_id, &conn).await else { err_handler!("Invalid device id") }; let Some(user) = User::find_by_uuid(&user_id, &conn).await else { err_handler!("Device has no user associated") }; if user.security_stamp != claims.sstamp { if let Some(stamp_exception) = user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::(s).ok()) { let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else { err_handler!("Error getting current route for stamp exception") }; // Check if the stamp exception has expired first. // Then, check if the current route matches any of the allowed routes. // After that check the stamp in exception matches the one in the claims. if Utc::now().timestamp() > stamp_exception.expire { // If the stamp exception has been expired remove it from the database. // This prevents checking this stamp exception for new requests. let mut user = user; user.reset_stamp_exception(); if let Err(e) = user.save(&conn).await { error!("Error updating user: {e:#?}"); } err_handler!("Stamp exception is expired") } else if !stamp_exception.routes.contains(¤t_route.to_string()) { err_handler!("Invalid security stamp: Current route and exception route do not match") } else if stamp_exception.security_stamp != claims.sstamp { err_handler!("Invalid security stamp for matched stamp exception") } } else { err_handler!("Invalid security stamp") } } Outcome::Success(Headers { host, device, user, ip, }) } } pub struct OrgHeaders { pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, pub membership_status: MembershipStatus, pub membership: Membership, pub ip: ClientIp, } impl OrgHeaders { fn is_member(&self) -> bool { // NOTE: we don't care about MembershipStatus at the moment because this is only used // where an invited, accepted or confirmed user is expected if this ever changes or // if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly self.membership_type >= MembershipType::User } fn is_confirmed_and_admin(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin } fn is_confirmed_and_manager(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Manager } fn is_confirmed_and_owner(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner } } #[rocket::async_trait] impl<'r> FromRequest<'r> for OrgHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(Headers::from_request(request).await); // org_id is usually the second path param ("/organizations/"), // but there are cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. let url_org_id: Option = { if let Some(Ok(org_id)) = request.param::(1) { Some(org_id) } else if let Some(Ok(org_id)) = request.query_value::("organizationId") { Some(org_id) } else { None } }; match url_org_id { Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => { let conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; let user = headers.user; let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await else { err_handler!("The current user isn't member of the organization"); }; Outcome::Success(Self { host: headers.host, device: headers.device, user, membership_type: { if let Some(member_type) = MembershipType::from_i32(membership.atype) { member_type } else { // This should only happen if the DB is corrupted err_handler!("Unknown user type in the database") } }, membership_status: { if let Some(member_status) = MembershipStatus::from_i32(membership.status) { // NOTE: add additional check for revoked if from_i32 is ever changed // to return Revoked status. member_status } else { err_handler!("User status is either revoked or invalid.") } }, membership, ip: headers.ip, }) } _ => err_handler!("Error getting the organization id"), } } } pub struct AdminHeaders { pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, pub ip: ClientIp, pub org_id: OrganizationId, } #[rocket::async_trait] impl<'r> FromRequest<'r> for AdminHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_admin() { Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, membership_type: headers.membership_type, ip: headers.ip, org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be Admin or Owner to call this endpoint") } } } // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. fn get_col_id(request: &Request<'_>) -> Option { if let Some(Ok(col_id)) = request.param::(3) { if uuid::Uuid::parse_str(&col_id).is_ok() { return Some(col_id.into()); } } if let Some(Ok(col_id)) = request.query_value::("collectionId") { if uuid::Uuid::parse_str(&col_id).is_ok() { return Some(col_id.into()); } } None } /// The ManagerHeaders are used to check if you are at least a Manager /// and have access to the specific collection provided via the /collections/collectionId. /// This does strict checking on the collection_id, ManagerHeadersLoose does not. pub struct ManagerHeaders { pub host: String, pub device: Device, pub user: User, pub ip: ClientIp, pub org_id: OrganizationId, } #[rocket::async_trait] impl<'r> FromRequest<'r> for ManagerHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_manager() { match get_col_id(request) { Some(col_id) => { let conn = match DbConn::from_request(request).await { Outcome::Success(conn) => conn, _ => err_handler!("Error getting DB"), }; if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await { err_handler!("The current user isn't a manager for this collection") } } _ => err_handler!("Error getting the collection id"), } Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, ip: headers.ip, org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") } } } impl From for Headers { fn from(h: ManagerHeaders) -> Headers { Headers { host: h.host, device: h.device, user: h.user, ip: h.ip, } } } /// The ManagerHeadersLoose is used when you at least need to be a Manager, /// but there is no collection_id sent with the request (either in the path or as form data). pub struct ManagerHeadersLoose { pub host: String, pub device: Device, pub user: User, pub membership: Membership, pub ip: ClientIp, } #[rocket::async_trait] impl<'r> FromRequest<'r> for ManagerHeadersLoose { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_manager() { Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, membership: headers.membership, ip: headers.ip, }) } else { err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") } } } impl From for Headers { fn from(h: ManagerHeadersLoose) -> Headers { Headers { host: h.host, device: h.device, user: h.user, ip: h.ip, } } } impl ManagerHeaders { pub async fn from_loose( h: ManagerHeadersLoose, collections: &Vec, conn: &DbConn, ) -> Result { for col_id in collections { if uuid::Uuid::parse_str(col_id.as_ref()).is_err() { err!("Collection Id is malformed!"); } if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await { err!("Collection not found", "The current user isn't a manager for this collection") } } Ok(ManagerHeaders { host: h.host, device: h.device, user: h.user, ip: h.ip, org_id: h.membership.org_uuid, }) } } pub struct OwnerHeaders { pub device: Device, pub user: User, pub ip: ClientIp, pub org_id: OrganizationId, } #[rocket::async_trait] impl<'r> FromRequest<'r> for OwnerHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_owner() { Outcome::Success(Self { device: headers.device, user: headers.user, ip: headers.ip, org_id: headers.membership.org_uuid, }) } else { err_handler!("You need to be Owner to call this endpoint") } } } pub struct OrgMemberHeaders { pub host: String, pub device: Device, pub user: User, pub membership: Membership, pub ip: ClientIp, } #[rocket::async_trait] impl<'r> FromRequest<'r> for OrgMemberHeaders { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_member() { Outcome::Success(Self { host: headers.host, device: headers.device, user: headers.user, membership: headers.membership, ip: headers.ip, }) } else { err_handler!("You need to be a Member of the Organization to call this endpoint") } } } impl From for Headers { fn from(h: OrgMemberHeaders) -> Headers { Headers { host: h.host, device: h.device, user: h.user, ip: h.ip, } } } // // Client IP address detection // pub struct ClientIp { pub ip: IpAddr, } #[rocket::async_trait] impl<'r> FromRequest<'r> for ClientIp { type Error = (); async fn from_request(req: &'r Request<'_>) -> Outcome { let ip = if CONFIG._ip_header_enabled() { req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { match ip.find(',') { Some(idx) => &ip[..idx], None => ip, } .parse() .map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header())) .ok() }) } else { None }; let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap()); Outcome::Success(ClientIp { ip, }) } } pub struct Secure { pub https: bool, } #[rocket::async_trait] impl<'r> FromRequest<'r> for Secure { type Error = (); async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); // Try to guess from the headers let protocol = match headers.get_one("X-Forwarded-Proto") { Some(proto) => proto, None => { if env::var("ROCKET_TLS").is_ok() { "https" } else { "http" } } }; Outcome::Success(Secure { https: protocol == "https", }) } } pub struct WsAccessTokenHeader { pub access_token: Option, } #[rocket::async_trait] impl<'r> FromRequest<'r> for WsAccessTokenHeader { type Error = (); async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); // Get access_token let access_token = match headers.get_one("Authorization") { Some(a) => a.rsplit("Bearer ").next().map(String::from), None => None, }; Outcome::Success(Self { access_token, }) } } pub struct ClientVersion(pub semver::Version); #[rocket::async_trait] impl<'r> FromRequest<'r> for ClientVersion { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = request.headers(); let Some(version) = headers.get_one("Bitwarden-Client-Version") else { err_handler!("No Bitwarden-Client-Version header provided") }; let Ok(version) = semver::Version::parse(version) else { err_handler!("Invalid Bitwarden-Client-Version header provided") }; Outcome::Success(ClientVersion(version)) } } #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AuthMethod { OrgApiKey, Password, Sso, UserApiKey, } impl AuthMethod { pub fn scope(&self) -> String { match self { AuthMethod::OrgApiKey => "api.organization".to_string(), AuthMethod::Password => "api offline_access".to_string(), AuthMethod::Sso => "api offline_access".to_string(), AuthMethod::UserApiKey => "api".to_string(), } } pub fn scope_vec(&self) -> Vec { self.scope().split_whitespace().map(str::to_string).collect() } pub fn check_scope(&self, scope: Option<&String>) -> ApiResult { let method_scope = self.scope(); match scope { None => err!("Missing scope"), Some(scope) if scope == &method_scope => Ok(method_scope), Some(scope) => err!(format!("Scope ({scope}) not supported")), } } } #[derive(Debug, Serialize, Deserialize)] pub enum TokenWrapper { Access(String), Refresh(String), } #[derive(Debug, Serialize, Deserialize)] pub struct RefreshJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: AuthMethod, pub device_token: String, pub token: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct AuthTokens { pub refresh_claims: RefreshJwtClaims, pub access_claims: LoginJwtClaims, } impl AuthTokens { pub fn refresh_token(&self) -> String { encode_jwt(&self.refresh_claims) } pub fn access_token(&self) -> String { self.access_claims.token() } pub fn expires_in(&self) -> i64 { self.access_claims.expires_in() } pub fn scope(&self) -> String { self.refresh_claims.sub.scope() } // Create refresh_token and access_token with default validity pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option) -> Self { let time_now = Utc::now(); let access_claims = LoginJwtClaims::default(device, user, &sub, client_id); let validity = if device.is_mobile() { *MOBILE_REFRESH_VALIDITY } else { *DEFAULT_REFRESH_VALIDITY }; let refresh_claims = RefreshJwtClaims { nbf: time_now.timestamp(), exp: (time_now + validity).timestamp(), iss: JWT_LOGIN_ISSUER.to_string(), sub, device_token: device.refresh_token.clone(), token: None, }; Self { refresh_claims, access_claims, } } } pub async fn refresh_tokens( ip: &ClientIp, refresh_token: &str, client_id: Option, conn: &DbConn, ) -> ApiResult<(Device, AuthTokens)> { let refresh_claims = match decode_refresh(refresh_token) { Err(err) => { error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip); //err_silent!(format!("Impossible to read refresh_token: {}", err.message())) // If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string. // We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't // check expiration or issuer, so they're not included here. RefreshJwtClaims { nbf: 0, exp: 0, iss: String::new(), sub: AuthMethod::Password, device_token: refresh_token.into(), token: None, } } Ok(claims) => claims, }; // Get device by refresh token let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await { None => err!("Invalid refresh token"), Some(device) => device, }; // Save to update `updated_at`. device.save(true, conn).await?; let user = match User::find_by_uuid(&device.user_uuid, conn).await { None => err!("Impossible to find user"), Some(user) => user, }; let auth_tokens = match refresh_claims.sub { AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => { AuthTokens::new(&device, &user, refresh_claims.sub, client_id) } AuthMethod::Sso if CONFIG.sso_enabled() => { sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await? } AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"), AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"), AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id), _ => err!("Invalid auth method, cannot refresh token"), }; Ok((device, auth_tokens)) } ================================================ FILE: src/config.rs ================================================ use std::{ env::consts::EXE_SUFFIX, fmt, process::exit, sync::{ atomic::{AtomicBool, Ordering}, LazyLock, RwLock, }, }; use job_scheduler_ng::Schedule; use reqwest::Url; use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor}; use crate::{ error::Error, util::{get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags}, }; static CONFIG_FILE: LazyLock = LazyLock::new(|| { let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data")); get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) }); static CONFIG_FILE_PARENT_DIR: LazyLock = LazyLock::new(|| { let path = std::path::PathBuf::from(&*CONFIG_FILE); path.parent().unwrap_or(std::path::Path::new("data")).to_str().unwrap_or("data").to_string() }); static CONFIG_FILENAME: LazyLock = LazyLock::new(|| { let path = std::path::PathBuf::from(&*CONFIG_FILE); path.file_name().unwrap_or(std::ffi::OsStr::new("config.json")).to_str().unwrap_or("config.json").to_string() }); pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false); pub static CONFIG: LazyLock = LazyLock::new(|| { std::thread::spawn(|| { let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap_or_else(|e| { println!("Error loading config:\n {e:?}\n"); exit(12) }); rt.block_on(Config::load()).unwrap_or_else(|e| { println!("Error loading config:\n {e:?}\n"); exit(12) }) }) .join() .unwrap_or_else(|e| { println!("Error loading config:\n {e:?}\n"); exit(12) }) }); pub type Pass = String; macro_rules! make_config { // Support string print ( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option with "***" ( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***" ( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json ( @supportstr $name:ident, $value:expr, String, $none_action:ident ) => { $value.as_str().into() }; // Required string value, we convert to json ( @supportstr $name:ident, $value:expr, $ty:ty, $none_action:ident ) => { ($value).into() }; // Required other value, we return as is or convert to json // Group or empty string ( @show ) => { "" }; ( @show $lit:literal ) => { $lit }; // Wrap the optionals in an Option type ( @type $ty:ty, option) => { Option<$ty> }; ( @type $ty:ty, $id:ident) => { $ty }; // Generate the values depending on none_action ( @build $value:expr, $config:expr, option, ) => { $value }; ( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) }; ( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{ match $value { Some(v) => v, None => { let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; f($config) } } }}; ( @build $value:expr, $config:expr, generated, $default_fn:expr ) => {{ let f: &dyn Fn(&ConfigItems) -> _ = &$default_fn; f($config) }}; ( @getenv $name:expr, bool ) => { get_env_bool($name) }; ( @getenv $name:expr, $ty:ident ) => { get_env($name) }; ($( $(#[doc = $groupdoc:literal])? $group:ident $(: $group_enabled:ident)? { $( $(#[doc = $doc:literal])+ $name:ident : $ty:ident, $editable:literal, $none_action:ident $(, $default:expr)?; )+}, )+) => { pub struct Config { inner: RwLock } struct Inner { rocket_shutdown_handle: Option, templates: Handlebars<'static>, config: ConfigItems, _env: ConfigBuilder, _usr: ConfigBuilder, _overrides: Vec<&'static str>, } // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html // This deserialize doesn't care if there are keys missing, or if there are duplicate keys // In case of duplicate keys (which should never be possible unless manually edited), the last value is used! // Main reason for this is removing the `visit_seq` function, which causes a lot of code generation not needed or used for this struct. impl<'de> Deserialize<'de> for ConfigBuilder { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { const FIELDS: &[&str] = &[ $($( stringify!($name), )+)+ ]; #[allow(non_camel_case_types)] enum Field { $($( $name, )+)+ __ignore, } impl<'de> Deserialize<'de> for Field { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct FieldVisitor; impl Visitor<'_> for FieldVisitor { type Value = Field; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("ConfigBuilder field identifier") } #[inline] fn visit_str(self, value: &str) -> Result where E: de::Error, { match value { $($( stringify!($name) => Ok(Field::$name), )+)+ _ => Ok(Field::__ignore), } } } deserializer.deserialize_identifier(FieldVisitor) } } struct ConfigBuilderVisitor; impl<'de> Visitor<'de> for ConfigBuilderVisitor { type Value = ConfigBuilder; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str("struct ConfigBuilder") } #[inline] fn visit_map(self, mut map: A) -> Result where A: MapAccess<'de>, { let mut builder = ConfigBuilder::default(); while let Some(key) = map.next_key()? { match key { $($( Field::$name => { if builder.$name.is_some() { return Err(de::Error::duplicate_field(stringify!($name))); } builder.$name = map.next_value()?; } )+)+ Field::__ignore => { let _ = map.next_value::()?; } } } Ok(builder) } } deserializer.deserialize_struct("ConfigBuilder", FIELDS, ConfigBuilderVisitor) } } #[derive(Clone, Default, Serialize)] pub struct ConfigBuilder { $($( #[serde(skip_serializing_if = "Option::is_none")] $name: Option<$ty>, )+)+ } impl ConfigBuilder { fn from_env() -> Self { let env_file = get_env("ENV_FILE").unwrap_or_else(|| String::from(".env")); match dotenvy::from_path(&env_file) { Ok(_) => { println!("[INFO] Using environment file `{env_file}` for configuration.\n"); }, Err(e) => match e { dotenvy::Error::LineParse(msg, pos) => { println!("[ERROR] Failed parsing environment file: `{env_file}`\nNear {msg:?} on position {pos}\nPlease fix and restart!\n"); exit(255); }, dotenvy::Error::Io(ioerr) => match ioerr.kind() { std::io::ErrorKind::NotFound => { // Only exit if this environment variable is set, but the file was not found. // This prevents incorrectly configured environments. if let Some(env_file) = get_env::("ENV_FILE") { println!("[ERROR] The configured ENV_FILE `{env_file}` was not found!\n"); exit(255); } }, std::io::ErrorKind::PermissionDenied => { println!("[ERROR] Permission denied while trying to read environment file `{env_file}`!\n"); exit(255); }, _ => { println!("[ERROR] Reading environment file `{env_file}` failed:\n{ioerr:?}\n"); exit(255); } }, _ => { println!("[ERROR] Reading environment file `{env_file}` failed:\n{e:?}\n"); exit(255); } } }; let mut builder = ConfigBuilder::default(); $($( builder.$name = make_config! { @getenv pastey::paste!(stringify!([<$name:upper>])), $ty }; )+)+ builder } async fn from_file() -> Result { let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let config_bytes = operator.read(&CONFIG_FILENAME).await?; println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) } fn clear_non_editable(&mut self) { $($( if !$editable { self.$name = None; } )+)+ } /// Merges the values of both builders into a new builder. /// If both have the same element, `other` wins. fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self { let mut builder = self.clone(); $($( if let v @Some(_) = &other.$name { builder.$name = v.clone(); if self.$name.is_some() { overrides.push(pastey::paste!(stringify!([<$name:upper>]))); } } )+)+ if show_overrides && !overrides.is_empty() { // We can't use warn! here because logging isn't setup yet. println!("[WARNING] The following environment variables are being overridden by the config.json file."); println!("[WARNING] Please use the admin panel to make changes to them:"); println!("[WARNING] {}\n", overrides.join(", ")); } builder } fn build(&self) -> ConfigItems { let mut config = ConfigItems::default(); let _domain_set = self.domain.is_some(); $($( config.$name = make_config! { @build self.$name.clone(), &config, $none_action, $($default)? }; )+)+ config.domain_set = _domain_set; config.domain = config.domain.trim_end_matches('/').to_string(); config.signups_domains_whitelist = config.signups_domains_whitelist.trim().to_lowercase(); config.org_creation_users = config.org_creation_users.trim().to_lowercase(); // Copy the values from the deprecated flags to the new ones if config.http_request_block_regex.is_none() { config.http_request_block_regex = config.icon_blacklist_regex.clone(); } config } } #[derive(Clone, Default)] struct ConfigItems { $($( $name: make_config! {@type $ty, $none_action}, )+)+ } #[derive(Serialize)] struct ElementDoc { name: &'static str, description: &'static str, } #[derive(Serialize)] struct ElementData { editable: bool, name: &'static str, value: serde_json::Value, default: serde_json::Value, #[serde(rename = "type")] r#type: &'static str, doc: ElementDoc, overridden: bool, } #[derive(Serialize)] pub struct GroupData { group: &'static str, grouptoggle: &'static str, groupdoc: &'static str, elements: Vec, } #[allow(unused)] impl Config { $($( $(#[doc = $doc])+ pub fn $name(&self) -> make_config! {@type $ty, $none_action} { self.inner.read().unwrap().config.$name.clone() } )+)+ pub fn prepare_json(&self) -> serde_json::Value { let (def, cfg, overridden) = { // Lock the inner as short as possible and clone what is needed to prevent deadlocks let inner = &self.inner.read().unwrap(); (inner._env.build(), inner.config.clone(), inner._overrides.clone()) }; fn _get_form_type(rust_type: &'static str) -> &'static str { match rust_type { "Pass" => "password", "String" => "text", "bool" => "checkbox", _ => "number" } } fn _get_doc(doc_str: &'static str) -> ElementDoc { let mut split = doc_str.split("|>").map(str::trim); ElementDoc { name: split.next().unwrap_or_default(), description: split.next().unwrap_or_default(), } } let data: Vec = vec![ $( // This repetition is for each group GroupData { group: stringify!($group), grouptoggle: stringify!($($group_enabled)?), groupdoc: (make_config! { @show $($groupdoc)? }), elements: vec![ $( // This repetition is for each element within a group ElementData { editable: $editable, name: stringify!($name), value: serde_json::to_value(&cfg.$name).unwrap_or_default(), default: serde_json::to_value(&def.$name).unwrap_or_default(), r#type: _get_form_type(stringify!($ty)), doc: _get_doc(concat!($($doc),+)), overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))), }, )+], // End of elements repetition }, )+]; // End of groups repetition serde_json::to_value(data).unwrap() } pub fn get_support_json(&self) -> serde_json::Value { // Define which config keys need to be masked. // Pass types will always be masked and no need to put them in the list. // Besides Pass, only String types will be masked via _privacy_mask. const PRIVACY_CONFIG: &[&str] = &[ "allowed_connect_src", "allowed_iframe_ancestors", "database_url", "domain_origin", "domain_path", "domain", "helo_name", "org_creation_users", "signups_domains_whitelist", "_smtp_img_src", "smtp_from_name", "smtp_from", "smtp_host", "smtp_username", "sso_authority", "sso_callback_path", "sso_client_id", ]; let cfg = { // Lock the inner as short as possible and clone what is needed to prevent deadlocks let inner = &self.inner.read().unwrap(); inner.config.clone() }; /// We map over the string and remove all alphanumeric, _ and - characters. /// This is the fastest way (within micro-seconds) instead of using a regex (which takes mili-seconds) fn _privacy_mask(value: &str) -> String { let mut n: u16 = 0; let mut colon_match = false; value .chars() .map(|c| { n += 1; match c { ':' if n <= 11 => { colon_match = true; c } '/' if n <= 13 && colon_match => c, ',' => c, _ => '*', } }) .collect::() } serde_json::Value::Object({ let mut json = serde_json::Map::new(); $($( json.insert(String::from(stringify!($name)), make_config! { @supportstr $name, cfg.$name, $ty, $none_action }); )+)+; // Loop through all privacy sensitive keys and mask them for mask_key in PRIVACY_CONFIG { if let Some(value) = json.get_mut(*mask_key) { if let Some(s) = value.as_str() { *value = _privacy_mask(s).into(); } } } json }) } pub fn get_overrides(&self) -> Vec<&'static str> { let overrides = { let inner = &self.inner.read().unwrap(); inner._overrides.clone() }; overrides } } }; } //STRUCTURE: // /// Short description (without this they won't appear on the list) // group { // /// Friendly Name |> Description (Optional) // name: type, is_editable, action, // } // // Where action applied when the value wasn't provided and can be: // def: Use a default value // auto: Value is auto generated based on other values // option: Value is optional // generated: Value is always autogenerated and it's original value ignored make_config! { folders { /// Data folder |> Main data folder data_folder: String, false, def, "data".to_string(); /// Database URL database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder); /// Icon cache folder icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder); /// Attachments folder attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder); /// Sends folder sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder); /// Temp folder |> Used for storing temporary file uploads tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder); /// Templates folder templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder); /// Session JWT key rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder); /// Web vault folder web_vault_folder: String, false, def, "web-vault/".to_string(); }, ws { /// Enable websocket notifications enable_websocket: bool, false, def, true; }, push { /// Enable push notifications push_enabled: bool, false, def, false; /// Push relay uri push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string(); /// Push identity uri push_identity_uri: String, false, def, "https://identity.bitwarden.com".to_string(); /// Installation id |> The installation id from https://bitwarden.com/host push_installation_id: Pass, false, def, String::new(); /// Installation key |> The installation key from https://bitwarden.com/host push_installation_key: Pass, false, def, String::new(); }, jobs { /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Set to 0 to globally disable scheduled jobs. job_poll_interval_ms: u64, false, def, 30_000; /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Defaults to hourly. Set blank to disable this job. send_purge_schedule: String, false, def, "0 5 * * * *".to_string(); /// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently. /// Defaults to daily. Set blank to disable this job. trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string(); /// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins. /// Defaults to once every minute. Set blank to disable this job. incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string(); /// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency access grantors. /// Defaults to hourly. (3 minutes after the hour) Set blank to disable this job. emergency_notification_reminder_schedule: String, false, def, "0 3 * * * *".to_string(); /// Emergency request timeout schedule |> Cron schedule of the job that grants emergency access requests that have met the required wait time. /// Defaults to hourly. (7 minutes after the hour) Set blank to disable this job. emergency_request_timeout_schedule: String, false, def, "0 7 * * * *".to_string(); /// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table. /// Defaults to daily. Set blank to disable this job. event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string(); /// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. /// Defaults to every minute. Set blank to disable this job. auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string(); /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. /// Defaults to once every minute. Set blank to disable this job. duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. /// Defaults to daily. Set blank to disable this job. purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_string(); }, /// General settings settings { /// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://' /// and port, if it's different than the default. Some server functions don't work correctly without this value domain: String, true, def, "http://localhost".to_string(); /// Domain Set |> Indicates if the domain is set by the admin. Otherwise the default will be used. domain_set: bool, false, def, false; /// Domain origin |> Domain URL origin (in https://example.com:8443/path, https://example.com:8443 is the origin) domain_origin: String, false, auto, |c| extract_url_origin(&c.domain); /// Domain path |> Domain URL path (in https://example.com:8443/path, /path is the path) domain_path: String, false, auto, |c| extract_url_path(&c.domain); /// Enable web vault web_vault_enabled: bool, false, def, true; /// Allow Sends |> Controls whether users are allowed to create Bitwarden Sends. /// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy. sends_allowed: bool, true, def, true; /// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key hibp_api_key: Pass, true, option; /// Per-user attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per user. When this limit is reached, the user will not be allowed to upload further attachments. user_attachment_limit: i64, true, option; /// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org. org_attachment_limit: i64, true, option; /// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends. user_send_limit: i64, true, option; /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. /// If unset, trashed items are not auto-deleted. This setting applies globally, so make /// sure to inform all users of any changes to this setting. trash_auto_delete_days: i64, true, option; /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// where the correct master password was provided but the required 2FA step was not completed, /// which potentially indicates a master password compromise. Set to 0 to disable this check. /// This setting applies globally to all users. incomplete_2fa_time_limit: i64, true, def, 3; /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external /// network requests. $ICON_CACHE_TTL must also be set to 0; otherwise, the existing icons /// will be deleted eventually, but won't be downloaded again. disable_icon_download: bool, true, def, false; /// Allow new signups |> Controls whether new users can register. Users can be invited by the vaultwarden admin even if this is disabled signups_allowed: bool, true, def, true; /// Require email verification on signups. On new client versions, this will require verification at signup time. On older clients, /// this will prevent logins from succeeding until the address has been verified signups_verify: bool, true, def, false; /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) signups_verify_resend_time: u64, true, def, 3_600; /// 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; /// 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, String::new(); /// Enable event logging |> Enables event logging for organizations. org_events_enabled: bool, false, def, false; /// Org creation users |> Allow org creation only by this list of comma-separated user emails. /// Blank or 'all' means all users can create orgs; 'none' means no users can create orgs. org_creation_users: String, true, def, String::new(); /// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled invitations_allowed: bool, true, def, true; /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// email verification token and deletion request token will expire (must be at least 1) invitation_expiration_hours: u32, false, def, 120; /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. emergency_access_allowed: bool, true, def, true; /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. email_change_allowed: bool, true, def, true; /// Password iterations |> Number of server-side passwords hashing iterations for the password hash. /// The default for new users. If changed, it will be updated during login for existing users. password_iterations: i32, true, def, 600_000; /// Allow password hints |> Controls whether users can set or show password hints. This setting applies globally to all users. password_hints_allowed: bool, true, def, true; /// Show password hint (Know the risks!) |> Controls whether a password hint should be shown directly in the web page /// if SMTP service is not configured and password hints are allowed. Not recommended for publicly-accessible instances /// because this provides unauthenticated access to potentially sensitive data. show_password_hint: bool, true, def, false; /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! admin_token: Pass, true, option; /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization invitation_org_name: String, true, def, "Vaultwarden".to_string(); /// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. events_days_retain: i64, false, option; }, /// Advanced settings advanced { /// Client IP header |> If not present, the remote IP is used. /// Set to the string "none" (without quotes), to disable any headers and just use the remote IP ip_header: String, true, def, "X-Real-IP".to_string(); /// Internal IP header property, used to avoid recomputing each time _ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none"; /// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// To specify a custom icon service, set a URL template with exactly one instance of `{}`, /// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`. /// `internal` refers to Vaultwarden's built-in icon fetching implementation. If an external /// service is set, an icon request to Vaultwarden will return an HTTP redirect to the /// corresponding icon at the external service. icon_service: String, false, def, "internal".to_string(); /// _icon_service_url _icon_service_url: String, false, generated, |c| generate_icon_service_url(&c.icon_service); /// _icon_service_csp _icon_service_csp: String, false, generated, |c| generate_icon_service_csp(&c.icon_service, &c._icon_service_url); /// Icon redirect code |> The HTTP status code to use for redirects to an external icon service. /// The supported codes are 301 (legacy permanent), 302 (legacy temporary), 307 (temporary), and 308 (permanent). /// Temporary redirects are useful while testing different icon services, but once a service /// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// are currently better supported by the Bitwarden clients. icon_redirect_code: u32, true, def, 302; /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed icon_cache_ttl: u64, true, def, 2_592_000; /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. icon_cache_negttl: u64, true, def, 259_200; /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. icon_download_timeout: u64, true, def, 10; /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead icon_blacklist_regex: String, false, option; /// [Deprecated] Icon blacklist non global IPs |> Use `http_request_block_non_global_ips` instead icon_blacklist_non_global_ips: bool, false, def, true; /// Block HTTP domains/IPs by Regex |> Any domains or IPs that match this regex won't be fetched by the internal HTTP client. /// Useful to hide other servers in the local network. Check the WIKI for more details http_request_block_regex: String, true, option; /// Block non global IPs |> Enabling this will cause the internal HTTP client to refuse to connect to any non global IP address. /// Useful to secure your internal environment: See https://en.wikipedia.org/wiki/Reserved_IP_addresses for a list of IPs which it will block http_request_block_non_global_ips: bool, true, auto, |c| c.icon_blacklist_non_global_ips; /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. /// Note that the checkbox would still be present, but ignored. disable_2fa_remember: bool, true, def, false; /// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid /// TOTP codes of the previous and next 30 seconds will be invalid. authenticator_disable_time_drift: bool, true, def, false; /// Customize the enabled feature flags on the clients |> This is a comma separated list of feature flags to enable. experimental_client_feature_flags: String, false, def, String::new(); /// Require new device emails |> When a user logs in an email is required to be sent. /// If sending the email fails the login attempt will fail. require_device_email: bool, true, def, false; /// Reload templates (Dev) |> When this is set to true, the templates get reloaded with every request. /// ONLY use this during development, as it can slow down the server reload_templates: bool, true, def, false; /// Enable extended logging extended_logging: bool, false, def, true; /// Log timestamp format log_timestamp_format: String, true, def, "%Y-%m-%d %H:%M:%S.%3f".to_string(); /// Enable the log to output to Syslog use_syslog: bool, false, def, false; /// Log file path log_file: String, false, option; /// Log level |> Valid values are "trace", "debug", "info", "warn", "error" and "off" /// For a specific module append it as a comma separated value "info,path::to::module=debug" log_level: String, false, def, "info".to_string(); /// Enable DB WAL |> Turning this off might lead to worse performance, but might help if using vaultwarden on some exotic filesystems, /// that do not support WAL. Please make sure you read project wiki on the topic before changing this setting. enable_db_wal: bool, false, def, true; /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; /// Timeout when acquiring database connection database_timeout: u64, false, def, 30; /// Timeout in seconds before idle connections to the database are closed database_idle_timeout: u64, false, def, 600; /// Database connection max pool size database_max_conns: u32, false, def, 10; /// Database connection min pool size database_min_conns: u32, false, def, 2; /// Database connection init |> SQL statements to run when creating a new database connection, mainly useful for connection-scoped pragmas. If empty, a database-specific default is used. database_conn_init: String, false, def, String::new(); /// Bypass admin page security (Know the risks!) |> Disables the Admin Token for the admin page so you may use your own auth in-front disable_admin_token: bool, false, def, false; /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); /// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature allowed_connect_src: String, true, def, String::new(); /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in login_ratelimit_seconds: u64, false, def, 60; /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 login_ratelimit_max_burst: u32, false, def, 10; /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in admin_ratelimit_seconds: u64, false, def, 300; /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` admin_ratelimit_max_burst: u32, false, def, 3; /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). admin_session_lifetime: i64, true, def, 20; /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; /// Generated max_note_size value to prevent if..else matching during every check _max_note_size: usize, false, generated, |c| if c.increase_note_size_limit {100_000} else {10_000}; /// Enforce Single Org with Reset Password Policy |> Enforce that the Single Org policy is enabled before setting the Reset Password policy /// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available. /// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy. enforce_single_org_with_reset_pw_policy: bool, false, def, false; /// Prefer IPv6 (AAAA) resolving |> This settings configures the DNS resolver to resolve IPv6 first, and if not available try IPv4 /// This could be useful in IPv6 only environments. dns_prefer_ipv6: bool, true, def, false; }, /// OpenID Connect SSO settings sso { /// Enabled sso_enabled: bool, true, def, false; /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; /// Allow email association |> Associate existing non-SSO user based on email sso_signups_match_email: bool, true, def, true; /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. sso_allow_unknown_email_verification: bool, true, def, false; /// Client ID sso_client_id: String, true, def, String::new(); /// Client Key sso_client_secret: Pass, true, def, String::new(); /// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`) sso_authority: String, true, def, String::new(); /// Authorization request scopes |> List the of the needed scope (`openid` is implicit) sso_scopes: String, true, def, "email profile".to_string(); /// Authorization request extra parameters sso_authorize_extra_params: String, true, def, String::new(); /// Use PKCE during Authorization flow sso_pkce: bool, true, def, true; /// Regex for additional trusted Id token audience |> By default only the client_id is trusted. sso_audience_trusted: String, true, option; /// CallBack Path |> Generated from Domain. sso_callback_path: String, true, generated, |c| generate_sso_callback_path(&c.domain); /// Optional SSO master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' sso_master_password_policy: String, true, option; /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; }, /// Yubikey settings yubico: _enable_yubico { /// Enabled _enable_yubico: bool, true, def, true; /// Client ID yubico_client_id: String, true, option; /// Secret Key yubico_secret_key: Pass, true, option; /// Server yubico_server: String, true, option; }, /// Global Duo settings (Note that users can override them) duo: _enable_duo { /// Enabled _enable_duo: bool, true, def, true; /// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2) duo_use_iframe: bool, false, def, false; /// Client Id duo_ikey: String, true, option; /// Client Secret duo_skey: Pass, true, option; /// Host duo_host: String, true, option; /// Application Key (generated automatically) _duo_akey: Pass, false, option; }, /// SMTP Email Settings smtp: _enable_smtp { /// Enabled _enable_smtp: bool, true, def, true; /// Use Sendmail |> Whether to send mail via the `sendmail` command use_sendmail: bool, true, def, false; /// Sendmail Command |> Which sendmail command to use. The one found in the $PATH is used if not specified. sendmail_command: String, false, option; /// Host smtp_host: String, true, option; /// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY smtp_ssl: bool, false, option; /// DEPRECATED smtp_explicit_tls |> DEPRECATED - Please use SMTP_SECURITY smtp_explicit_tls: bool, false, option; /// Secure SMTP |> ("starttls", "force_tls", "off") Enable a secure connection. Default is "starttls" (Explicit - ports 587 or 25), "force_tls" (Implicit - port 465) or "off", no encryption smtp_security: String, true, auto, |c| smtp_convert_deprecated_ssl_options(c.smtp_ssl, c.smtp_explicit_tls); // TODO: After deprecation make it `def, "starttls".to_string()` /// Port smtp_port: u16, true, auto, |c| if c.smtp_security == *"force_tls" {465} else if c.smtp_security == *"starttls" {587} else {25}; /// From Address smtp_from: String, true, def, String::new(); /// From Name smtp_from_name: String, true, def, "Vaultwarden".to_string(); /// Username smtp_username: String, true, option; /// Password smtp_password: Pass, true, option; /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. smtp_auth_mechanism: 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; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; /// Embed images as email attachments. smtp_embed_images: bool, true, def, true; /// _smtp_img_src _smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! smtp_debug: bool, false, def, false; /// Accept Invalid Certs (Know the risks!) |> DANGEROUS: Allow invalid certificates. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_certs: bool, true, def, false; /// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_hostnames: bool, true, def, false; }, /// Email 2FA Settings email_2fa: _enable_email_2fa { /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail); /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. email_expiration_time: u64, true, def, 600; /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent email_attempts_limit: u64, true, def, 3; /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy email_2fa_enforce_on_verified_invite: bool, true, def, false; /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed email_2fa_auto_fallback: bool, true, def, false; }, } fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { // Validate connection URL is valid and DB feature is enabled #[cfg(sqlite)] { use crate::db::DbConnType; let url = &cfg.database_url; if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') { let path = std::path::Path::new(&url); if let Some(parent) = path.parent() { if !parent.is_dir() { err!(format!( "SQLite database directory `{}` does not exist or is not a directory", parent.display() )); } } } } if cfg.password_iterations < 100_000 { err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); } let limit = 256; if cfg.database_max_conns < 1 || cfg.database_max_conns > limit { err!(format!("`DATABASE_MAX_CONNS` contains an invalid value. Ensure it is between 1 and {limit}.",)); } if cfg.database_min_conns < 1 || cfg.database_min_conns > limit { err!(format!("`DATABASE_MIN_CONNS` contains an invalid value. Ensure it is between 1 and {limit}.",)); } if cfg.database_min_conns > cfg.database_max_conns { err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",)); } if let Some(log_file) = &cfg.log_file { if std::fs::OpenOptions::new().append(true).create(true).open(log_file).is_err() { err!("Unable to write to log file", log_file); } } let dom = cfg.domain.to_lowercase(); if !dom.starts_with("http://") && !dom.starts_with("https://") { err!( "DOMAIN variable needs to contain the protocol (http, https). Use 'http[s]://bw.example.com' instead of 'bw.example.com'" ); } let connect_src = cfg.allowed_connect_src.to_lowercase(); for url in connect_src.split_whitespace() { if !url.starts_with("https://") || Url::parse(url).is_err() { err!("ALLOWED_CONNECT_SRC variable contains one or more invalid URLs. Only FQDN's starting with https are allowed"); } } let whitelist = &cfg.signups_domains_whitelist; if !whitelist.is_empty() && whitelist.split(',').any(|d| d.trim().is_empty()) { err!("`SIGNUPS_DOMAINS_WHITELIST` contains empty tokens"); } let org_creation_users = cfg.org_creation_users.trim().to_lowercase(); if !(org_creation_users.is_empty() || org_creation_users == "all" || org_creation_users == "none") && org_creation_users.split(',').any(|u| !u.contains('@')) { err!("`ORG_CREATION_USERS` contains invalid email addresses"); } if let Some(ref token) = cfg.admin_token { if token.trim().is_empty() && !cfg.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`."); } } if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) { err!( "Misconfigured Push Notification service\n\ ########################################################################################\n\ # It looks like you enabled Push Notification feature, but didn't configure it #\n\ # properly. Make sure the installation id and key from https://bitwarden.com/host are #\n\ # added to your configuration. #\n\ ########################################################################################\n" ) } if cfg.push_enabled { let push_relay_uri = cfg.push_relay_uri.to_lowercase(); if !push_relay_uri.starts_with("https://") { err!("`PUSH_RELAY_URI` must start with 'https://'.") } if Url::parse(&push_relay_uri).is_err() { err!("Invalid URL format for `PUSH_RELAY_URI`."); } let push_identity_uri = cfg.push_identity_uri.to_lowercase(); if !push_identity_uri.starts_with("https://") { err!("`PUSH_IDENTITY_URI` must start with 'https://'.") } if Url::parse(&push_identity_uri).is_err() { err!("Invalid URL format for `PUSH_IDENTITY_URI`."); } } // Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103 // Client (web-v2026.2.0): https://github.com/bitwarden/clients/blob/a2fefe804d8c9b4a56c42f9904512c5c5821e2f6/libs/common/src/enums/feature-flag.enum.ts#L12 // Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22 // iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 // // NOTE: Move deprecated flags to the utils::parse_experimental_client_feature_flags() DEPRECATED_FLAGS const! const KNOWN_FLAGS: &[&str] = &[ // Auth Team "pm-5594-safari-account-switching", // Autofill Team "inline-menu-positioning-improvements", "inline-menu-totp", "ssh-agent", // Key Management Team "ssh-key-vault-item", "pm-25373-windows-biometrics-v2", // Tools "export-attachments", // Mobile Team "anon-addy-self-host-alias", "simple-login-self-host-alias", "mutual-tls", "cxp-import-mobile", "cxp-export-mobile", // Webauthn Related Origins "pm-30529-webauthn-related-origins", ]; let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect(); if !invalid_flags.is_empty() { err!(format!("Unrecognized experimental client feature flags: {invalid_flags:?}.\n\n\ Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\ Supported flags: {KNOWN_FLAGS:?}")); } const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; if let Some(limit) = cfg.user_attachment_limit { if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { err!("`USER_ATTACHMENT_LIMIT` is out of bounds"); } } if let Some(limit) = cfg.org_attachment_limit { if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { err!("`ORG_ATTACHMENT_LIMIT` is out of bounds"); } } if let Some(limit) = cfg.user_send_limit { if !(0i64..=MAX_FILESIZE_KB).contains(&limit) { err!("`USER_SEND_LIMIT` is out of bounds"); } } if cfg._enable_duo && (cfg.duo_host.is_some() || cfg.duo_ikey.is_some() || cfg.duo_skey.is_some()) && !(cfg.duo_host.is_some() && cfg.duo_ikey.is_some() && cfg.duo_skey.is_some()) { err!("All Duo options need to be set for global Duo support") } if cfg.sso_enabled { if cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty() { err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") } validate_internal_sso_issuer_url(&cfg.sso_authority)?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; validate_sso_master_password_policy(&cfg.sso_master_password_policy)?; } if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") } if let Some(yubico_server) = &cfg.yubico_server { let yubico_server = yubico_server.to_lowercase(); if !yubico_server.starts_with("https://") { err!("`YUBICO_SERVER` must be a valid URL and start with 'https://'. Either unset this variable or provide a valid URL.") } } } if cfg._enable_smtp { match cfg.smtp_security.as_str() { "off" | "starttls" | "force_tls" => (), _ => err!( "`SMTP_SECURITY` is invalid. It needs to be one of the following options: starttls, force_tls or off" ), } if cfg.use_sendmail { let command = cfg.sendmail_command.clone().unwrap_or_else(|| format!("sendmail{EXE_SUFFIX}")); let mut path = std::path::PathBuf::from(&command); // Check if we can find the sendmail command to execute when no absolute path is given if !path.is_absolute() { let Ok(which_path) = which::which(&command) else { err!(format!("sendmail command {command} not found in $PATH")) }; path = which_path; } match path.metadata() { Err(err) if err.kind() == std::io::ErrorKind::NotFound => { err!(format!("sendmail command not found at `{path:?}`")) } Err(err) => { err!(format!("failed to access sendmail command at `{path:?}`: {err}")) } Ok(metadata) => { if metadata.is_dir() { err!(format!("sendmail command at `{path:?}` isn't a directory")); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if !metadata.permissions().mode() & 0o111 != 0 { err!(format!("sendmail command at `{path:?}` isn't executable")); } } } } } else { if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") } if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`") } } if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) } if cfg._enable_email_2fa && cfg.email_token_size < 6 { err!("`EMAIL_TOKEN_SIZE` has a minimum size of 6") } } if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { err!("To enable email 2FA, a mail transport must be configured") } if !cfg._enable_email_2fa && cfg.email_2fa_enforce_on_verified_invite { err!("To enforce email 2FA on verified invitations, email 2fa has to be enabled!"); } if !cfg._enable_email_2fa && cfg.email_2fa_auto_fallback { err!("To use email 2FA as automatic fallback, email 2fa has to be enabled!"); } // Check if the HTTP request block regex is valid if let Some(ref r) = cfg.http_request_block_regex { let validate_regex = regex::Regex::new(r); match validate_regex { Ok(_) => (), Err(e) => err!(format!("`HTTP_REQUEST_BLOCK_REGEX` is invalid: {e:#?}")), } } // Check if the icon service is valid let icon_service = cfg.icon_service.as_str(); match icon_service { "internal" | "bitwarden" | "duckduckgo" | "google" => (), _ => { if !icon_service.starts_with("http") { err!(format!("Icon service URL `{icon_service}` must start with \"http\"")) } match icon_service.matches("{}").count() { 1 => (), // nominal 0 => err!(format!("Icon service URL `{icon_service}` has no placeholder \"{{}}\"")), _ => err!(format!("Icon service URL `{icon_service}` has more than one placeholder \"{{}}\"")), } } } // Check if the icon redirect code is valid match cfg.icon_redirect_code { 301 | 302 | 307 | 308 => (), _ => err!("Only HTTP 301/302 and 307/308 redirects are supported"), } if cfg.invitation_expiration_hours < 1 { err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour") } // Validate schedule crontab format if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::().is_err() { err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression") } if !cfg.trash_purge_schedule.is_empty() && cfg.trash_purge_schedule.parse::().is_err() { err!("`TRASH_PURGE_SCHEDULE` is not a valid cron expression") } if !cfg.incomplete_2fa_schedule.is_empty() && cfg.incomplete_2fa_schedule.parse::().is_err() { err!("`INCOMPLETE_2FA_SCHEDULE` is not a valid cron expression") } if !cfg.emergency_notification_reminder_schedule.is_empty() && cfg.emergency_notification_reminder_schedule.parse::().is_err() { err!("`EMERGENCY_NOTIFICATION_REMINDER_SCHEDULE` is not a valid cron expression") } if !cfg.emergency_request_timeout_schedule.is_empty() && cfg.emergency_request_timeout_schedule.parse::().is_err() { err!("`EMERGENCY_REQUEST_TIMEOUT_SCHEDULE` is not a valid cron expression") } if !cfg.event_cleanup_schedule.is_empty() && cfg.event_cleanup_schedule.parse::().is_err() { err!("`EVENT_CLEANUP_SCHEDULE` is not a valid cron expression") } if !cfg.auth_request_purge_schedule.is_empty() && cfg.auth_request_purge_schedule.parse::().is_err() { err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") } if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { if let Err(e) = argon2::password_hash::PasswordHash::new(t) { err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'")) } } Some(_) => { println!( "[NOTICE] You are using a plain text `ADMIN_TOKEN` which is insecure.\n\ Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.\n\ See: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token\n" ); } _ => {} } } if cfg.increase_note_size_limit { println!("[WARNING] Secure Note size limit is increased to 100_000!"); println!("[WARNING] This could cause issues with clients. Also exports will not work on Bitwarden servers!."); } Ok(()) } fn validate_internal_sso_issuer_url(sso_authority: &String) -> Result { match openidconnect::IssuerUrl::new(sso_authority.clone()) { Err(err) => err!(format!("Invalid sso_authority URL ({sso_authority}): {err}")), Ok(issuer_url) => Ok(issuer_url), } } fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result { match openidconnect::RedirectUrl::new(sso_callback_path.clone()) { Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")), Ok(redirect_url) => Ok(redirect_url), } } fn validate_sso_master_password_policy( sso_master_password_policy: &Option, ) -> Result, Error> { let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); match policy { None => Ok(None), Some(Ok(jsobject @ serde_json::Value::Object(_))) => Ok(Some(jsobject)), Some(Ok(_)) => err!("Invalid sso_master_password_policy: parsed value is not a JSON object"), Some(Err(error)) => { err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''")) } } } /// Extracts an RFC 6454 web origin from a URL. fn extract_url_origin(url: &str) -> String { match Url::parse(url) { Ok(u) => u.origin().ascii_serialization(), Err(e) => { println!("Error validating domain: {e}"); String::new() } } } /// Extracts the path from a URL. /// All trailing '/' chars are trimmed, even if the path is a lone '/'. fn extract_url_path(url: &str) -> String { match Url::parse(url) { Ok(u) => u.path().trim_end_matches('/').to_string(), Err(_) => { // We already print it in the method above, no need to do it again String::new() } } } fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { "cid:".to_string() } else { // normalize base_url let base_url = domain.trim_end_matches('/'); format!("{base_url}/vw_static/") } } fn generate_sso_callback_path(domain: &str) -> String { // normalize base_url let base_url = domain.trim_end_matches('/'); format!("{base_url}/identity/connect/oidc-signin") } /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { match icon_service { "internal" => String::new(), "bitwarden" => "https://icons.bitwarden.net/{}/icon.png".to_string(), "duckduckgo" => "https://icons.duckduckgo.com/ip3/{}.ico".to_string(), "google" => "https://www.google.com/s2/favicons?domain={}&sz=32".to_string(), _ => icon_service.to_string(), } } /// Generate the CSP string needed to allow redirected icon fetching fn generate_icon_service_csp(icon_service: &str, icon_service_url: &str) -> String { // We split on the first '{', since that is the variable delimiter for an icon service URL. // Everything up until the first '{' should be fixed and can be used as an CSP string. let csp_string = match icon_service_url.split_once('{') { Some((c, _)) => c.to_string(), None => String::new(), }; // Because Google does a second redirect to there gstatic.com domain, we need to add an extra csp string. match icon_service { "google" => csp_string + " https://*.gstatic.com/favicon", _ => csp_string, } } /// Convert the old SMTP_SSL and SMTP_EXPLICIT_TLS options fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option, smtp_explicit_tls: Option) -> String { if smtp_explicit_tls.is_some() || smtp_ssl.is_some() { println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead."); } if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() { return "force_tls".to_string(); } else if smtp_ssl.is_some() && !smtp_ssl.unwrap() { return "off".to_string(); } // Return the default `starttls` in all other cases "starttls".to_string() } fn opendal_operator_for_path(path: &str) -> Result { // Cache of previously built operators by path static OPERATORS_BY_PATH: LazyLock> = LazyLock::new(dashmap::DashMap::new); if let Some(operator) = OPERATORS_BY_PATH.get(path) { return Ok(operator.clone()); } let operator = if path.starts_with("s3://") { #[cfg(not(s3))] return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into()); #[cfg(s3)] opendal_s3_operator_for_path(path)? } else { let builder = opendal::services::Fs::default().root(path); opendal::Operator::new(builder)?.finish() }; OPERATORS_BY_PATH.insert(path.to_string(), operator.clone()); Ok(operator) } #[cfg(s3)] fn opendal_s3_operator_for_path(path: &str) -> Result { use crate::http_client::aws::AwsReqwestConnector; use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; // This is a custom AWS credential loader that uses the official AWS Rust // SDK config crate to load credentials. This ensures maximum compatibility // with AWS credential configurations. For example, OpenDAL doesn't support // AWS SSO temporary credentials yet. struct OpenDALS3CredentialLoader {} #[async_trait] impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader { async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result> { use aws_credential_types::provider::ProvideCredentials as _; use tokio::sync::OnceCell; static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); let chain = DEFAULT_CREDENTIAL_CHAIN .get_or_init(|| { let reqwest_client = reqwest::Client::builder().build().unwrap(); let connector = AwsReqwestConnector { client: reqwest_client, }; let conf = ProviderConfig::default().with_http_client(connector); DefaultCredentialsChain::builder().configure(conf).build() }) .await; let creds = chain.provide_credentials().await?; Ok(Some(reqsign::AwsCredential { access_key_id: creds.access_key_id().to_string(), secret_access_key: creds.secret_access_key().to_string(), session_token: creds.session_token().map(|s| s.to_string()), expires_in: creds.expiry().map(|expiration| expiration.into()), })) } } const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {}; let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?; let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?; let builder = opendal::services::S3::default() .customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER)) .enable_virtual_host_style() .bucket(bucket) .root(url.path()) .default_storage_class("INTELLIGENT_TIERING"); Ok(opendal::Operator::new(builder)?.finish()) } pub enum PathType { Data, IconCache, Attachments, Sends, RsaKey, } impl Config { pub async fn load() -> Result { // Loading from env and file let _env = ConfigBuilder::from_env(); let _usr = ConfigBuilder::from_file().await.unwrap_or_default(); // Create merged config, config file overwrites env let mut _overrides = Vec::new(); let builder = _env.merge(&_usr, true, &mut _overrides); // Fill any missing with defaults let config = builder.build(); if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) { validate_config(&config)?; } Ok(Config { inner: RwLock::new(Inner { rocket_shutdown_handle: None, templates: load_templates(&config.templates_folder), config, _env, _usr, _overrides, }), }) } pub async fn update_config(&self, other: ConfigBuilder, ignore_non_editable: bool) -> Result<(), Error> { // Remove default values //let builder = other.remove(&self.inner.read().unwrap()._env); // TODO: Remove values that are defaults, above only checks those set by env and not the defaults let mut builder = other; // Remove values that are not editable if ignore_non_editable { builder.clear_non_editable(); } // Serialize now before we consume the builder let config_str = serde_json::to_string_pretty(&builder)?; // Prepare the combined config let mut overrides = Vec::new(); let config = { let env = &self.inner.read().unwrap()._env; env.merge(&builder, false, &mut overrides).build() }; validate_config(&config)?; // Save both the user and the combined config { let mut writer = self.inner.write().unwrap(); writer.config = config; writer._usr = builder; writer._overrides = overrides; } //Save to file let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; operator.write(&CONFIG_FILENAME, config_str).await?; Ok(()) } async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { let builder = { let usr = &self.inner.read().unwrap()._usr; let mut _overrides = Vec::new(); usr.merge(&other, false, &mut _overrides) }; self.update_config(builder, false).await } /// 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(); 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() } } // The registration link should be hidden if // - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed // - The SSO is activated and password login is disabled. pub fn is_signup_disabled(&self) -> bool { (!self.signups_allowed() && self.signups_domains_whitelist().is_empty() && (self.mail_enabled() || !self.invitations_allowed())) || (self.sso_enabled() && self.sso_only()) } /// Tests whether the specified user is allowed to create an organization. pub fn is_org_creation_allowed(&self, email: &str) -> bool { let users = self.org_creation_users(); if users.is_empty() || users == "all" { true } else if users == "none" { false } else { let email = email.to_lowercase(); users.split(',').any(|u| u.trim() == email) } } pub async fn delete_user_config(&self) -> Result<(), Error> { let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; operator.delete(&CONFIG_FILENAME).await?; // Empty user config let usr = ConfigBuilder::default(); // Config now is env + defaults let config = { let env = &self.inner.read().unwrap()._env; env.build() }; // Save configs { let mut writer = self.inner.write().unwrap(); writer.config = config; writer._usr = usr; writer._overrides = Vec::new(); } Ok(()) } pub fn private_rsa_key(&self) -> String { format!("{}.pem", self.rsa_key_filename()) } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) } pub async fn get_duo_akey(&self) -> String { if let Some(akey) = self._duo_akey() { akey } else { let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64); // Save the new value let builder = ConfigBuilder { _duo_akey: Some(akey_s.clone()), ..Default::default() }; self.update_config_partial(builder).await.ok(); akey_s } } pub fn is_webauthn_2fa_supported(&self) -> bool { Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() } /// 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 opendal_operator_for_path_type(&self, path_type: &PathType) -> Result { let path = match path_type { PathType::Data => self.data_folder(), PathType::IconCache => self.icon_cache_folder(), PathType::Attachments => self.attachments_folder(), PathType::Sends => self.sends_folder(), PathType::RsaKey => std::path::Path::new(&self.rsa_key_filename()) .parent() .ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))? .to_str() .ok_or_else(|| std::io::Error::other("Failed to convert RSA key file directory to UTF-8 string"))? .to_string(), }; opendal_operator_for_path(&path) } pub fn render_template(&self, name: &str, data: &T) -> Result { if self.reload_templates() { warn!("RELOADING TEMPLATES"); let hb = load_templates(CONFIG.templates_folder()); hb.render(name, data).map_err(Into::into) } else { let hb = &self.inner.read().unwrap().templates; hb.render(name, data).map_err(Into::into) } } pub fn render_fallback_template(&self, name: &str, data: &T) -> Result { let hb = &self.inner.read().unwrap().templates; hb.render(&format!("fallback_{name}"), data).map_err(Into::into) } pub fn set_rocket_shutdown_handle(&self, handle: rocket::Shutdown) { self.inner.write().unwrap().rocket_shutdown_handle = Some(handle); } pub fn shutdown(&self) { if let Ok(mut c) = self.inner.write() { if let Some(handle) = c.rocket_shutdown_handle.take() { handle.notify(); } } } pub fn sso_issuer_url(&self) -> Result { validate_internal_sso_issuer_url(&self.sso_authority()) } pub fn sso_redirect_url(&self) -> Result { validate_internal_sso_redirect_url(&self.sso_callback_path()) } pub fn sso_master_password_policy_value(&self) -> Option { validate_sso_master_password_policy(&self.sso_master_password_policy()).ok().flatten() } pub fn sso_scopes_vec(&self) -> Vec { self.sso_scopes().split_whitespace().map(str::to_string).collect() } pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> { url::form_urlencoded::parse(self.sso_authorize_extra_params().as_bytes()).into_owned().collect() } } use handlebars::{ Context, DirectorySourceOptions, Handlebars, Helper, HelperResult, Output, RenderContext, RenderErrorReason, Renderable, }; fn load_templates

(path: P) -> Handlebars<'static> where P: AsRef, { let mut hb = Handlebars::new(); // Error on missing params hb.set_strict_mode(true); // Register helpers hb.register_helper("case", Box::new(case_helper)); hb.register_helper("to_json", Box::new(to_json)); hb.register_helper("webver", Box::new(webver)); hb.register_helper("vwver", Box::new(vwver)); macro_rules! reg { ($name:expr) => {{ let template = include_str!(concat!("static/templates/", $name, ".hbs")); hb.register_template_string($name, template).unwrap(); }}; ($name:expr, $ext:expr) => {{ reg!($name); reg!(concat!($name, $ext)); }}; (@withfallback $name:expr) => {{ let template = include_str!(concat!("static/templates/", $name, ".hbs")); hb.register_template_string($name, template).unwrap(); hb.register_template_string(concat!("fallback_", $name), template).unwrap(); }}; } // First register default templates here reg!("email/email_header"); reg!("email/email_footer"); reg!("email/email_footer_text"); reg!("email/admin_reset_password", ".html"); reg!("email/change_email_existing", ".html"); reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); reg!("email/delete_account", ".html"); reg!("email/emergency_access_invite_accepted", ".html"); reg!("email/emergency_access_invite_confirmed", ".html"); reg!("email/emergency_access_recovery_approved", ".html"); reg!("email/emergency_access_recovery_initiated", ".html"); reg!("email/emergency_access_recovery_rejected", ".html"); reg!("email/emergency_access_recovery_reminder", ".html"); reg!("email/emergency_access_recovery_timed_out", ".html"); reg!("email/incomplete_2fa_login", ".html"); reg!("email/invite_accepted", ".html"); reg!("email/invite_confirmed", ".html"); reg!("email/new_device_logged_in", ".html"); reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); reg!("email/register_verify_email", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome_must_verify", ".html"); reg!("email/welcome", ".html"); reg!("admin/base"); reg!("admin/login"); reg!("admin/settings"); reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); reg!("404"); reg!(@withfallback "scss/vaultwarden.scss"); reg!("scss/user.vaultwarden.scss"); // And then load user templates to overwrite the defaults // Use .hbs extension for the files // Templates get registered with their relative name hb.register_templates_directory(path, DirectorySourceOptions::default()).unwrap(); hb } fn case_helper<'reg, 'rc>( h: &Helper<'rc>, r: &'reg Handlebars<'_>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output, ) -> HelperResult { let param = h.param(0).ok_or_else(|| RenderErrorReason::Other(String::from("Param not found for helper \"case\"")))?; let value = param.value().clone(); if h.params().iter().skip(1).any(|x| x.value() == &value) { h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or_else(|| Ok(())) } else { Ok(()) } } fn to_json<'reg, 'rc>( h: &Helper<'rc>, _r: &'reg Handlebars<'_>, _ctx: &'rc Context, _rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output, ) -> HelperResult { let param = h .param(0) .ok_or_else(|| RenderErrorReason::Other(String::from("Expected 1 parameter for \"to_json\"")))? .value(); let json = serde_json::to_string(param) .map_err(|e| RenderErrorReason::Other(format!("Can't serialize parameter to JSON: {e}")))?; out.write(&json)?; Ok(()) } // Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then. // The default is based upon the version since this feature is added. static WEB_VAULT_VERSION: LazyLock = LazyLock::new(|| { let vault_version = get_active_web_release(); // Use a single regex capture to extract version components let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap(); re.captures(&vault_version) .and_then(|c| { (c.len() == 4).then(|| { format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str()) }) }) .and_then(|v| semver::Version::parse(&v).ok()) .unwrap_or_else(|| semver::Version::parse("2024.6.2").unwrap()) }); // Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then. // The default is based upon the version since this feature is added. static VW_VERSION: LazyLock = LazyLock::new(|| { let vw_version = crate::VERSION.unwrap_or("1.32.5"); // Use a single regex capture to extract version components let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap(); re.captures(vw_version) .and_then(|c| { (c.len() == 4).then(|| { format!("{}.{}.{}", c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str(), c.get(3).unwrap().as_str()) }) }) .and_then(|v| semver::Version::parse(&v).ok()) .unwrap_or_else(|| semver::Version::parse("1.32.5").unwrap()) }); handlebars::handlebars_helper!(webver: | web_vault_version: String | semver::VersionReq::parse(&web_vault_version).expect("Invalid web-vault version compare string").matches(&WEB_VAULT_VERSION) ); handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); ================================================ FILE: src/crypto.rs ================================================ // // PBKDF2 derivation // use std::num::NonZeroU32; use data_encoding::{Encoding, HEXLOWER}; use ring::{digest, hmac, pbkdf2}; const DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256; const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN; pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec { let mut out = vec![0u8; OUTPUT_LEN]; // Initialize array with zeros let iterations = NonZeroU32::new(iterations).expect("Iterations can't be zero"); pbkdf2::derive(DIGEST_ALG, iterations, salt, secret, &mut out); out } pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterations: u32) -> bool { let iterations = NonZeroU32::new(iterations).expect("Iterations can't be zero"); pbkdf2::verify(DIGEST_ALG, iterations, salt, secret, previous).is_ok() } // // HMAC // pub fn hmac_sign(key: &str, data: &str) -> String { 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()) } // // Random values // /// Return an array holding `N` random bytes. pub fn get_random_bytes() -> [u8; N] { use ring::rand::{SecureRandom, SystemRandom}; let mut array = [0; N]; SystemRandom::new().fill(&mut array).expect("Error generating random values"); array } /// Encode random bytes using the provided function. pub fn encode_random_bytes(e: &Encoding) -> String { e.encode(&get_random_bytes::()) } /// Generates a random string over a specified alphabet. pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String { // Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html use rand::RngExt; let mut rng = rand::rng(); (0..num_chars) .map(|_| { let i = rng.random_range(0..alphabet.len()); char::from(alphabet[i]) }) .collect() } /// Generates a random numeric string. pub fn get_random_string_numeric(num_chars: usize) -> String { const ALPHABET: &[u8] = b"0123456789"; get_random_string(ALPHABET, num_chars) } /// Generates a random alphanumeric string. pub fn get_random_string_alphanum(num_chars: usize) -> String { const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ abcdefghijklmnopqrstuvwxyz\ 0123456789"; get_random_string(ALPHABET, num_chars) } pub fn generate_id() -> String { encode_random_bytes::(&HEXLOWER) } pub fn generate_send_file_id() -> String { // Send File IDs are globally scoped, so make them longer to avoid collisions. generate_id::<32>() // 256 bits } use crate::db::models::AttachmentId; pub fn generate_attachment_id() -> AttachmentId { // Attachment IDs are scoped to a cipher, so they can be smaller. AttachmentId(generate_id::<10>()) // 80 bits } /// Generates a numeric token for email-based verifications. pub fn generate_email_token(token_size: u8) -> String { get_random_string_numeric(token_size as usize) } /// Generates a personal API key. /// Upstream uses 30 chars, which is ~178 bits of entropy. pub fn generate_api_key() -> String { get_random_string_alphanum(30) } // // Constant time compare // pub fn ct_eq, U: AsRef<[u8]>>(a: T, b: U) -> bool { use subtle::ConstantTimeEq; a.as_ref().ct_eq(b.as_ref()).into() } ================================================ FILE: src/db/mod.rs ================================================ mod query_logger; use std::{ sync::{Arc, OnceLock}, time::Duration, }; use diesel::{ connection::SimpleConnection, r2d2::{CustomizeConnection, Pool, PooledConnection}, Connection, RunQueryDsl, }; use rocket::{ http::Status, request::{FromRequest, Outcome}, Request, }; use tokio::{ sync::{Mutex, OwnedSemaphorePermit, Semaphore}, time::timeout, }; use crate::{ error::{Error, MapResult}, CONFIG, }; // These changes are based on Rocket 0.5-rc wrapper of Diesel: https://github.com/SergioBenitez/Rocket/blob/v0.5-rc/contrib/sync_db_pools // A wrapper around spawn_blocking that propagates panics to the calling code. pub async fn run_blocking(job: F) -> R where F: FnOnce() -> R + Send + 'static, R: Send + 'static, { match tokio::task::spawn_blocking(job).await { Ok(ret) => ret, Err(e) => match e.try_into_panic() { Ok(panic) => std::panic::resume_unwind(panic), Err(_) => unreachable!("spawn_blocking tasks are never cancelled"), }, } } // This is used to generate the main DbConn and DbPool enums, which contain one variant for each database supported #[derive(diesel::MultiConnection)] pub enum DbConnInner { #[cfg(mysql)] Mysql(diesel::mysql::MysqlConnection), #[cfg(postgresql)] Postgresql(diesel::pg::PgConnection), #[cfg(sqlite)] Sqlite(diesel::sqlite::SqliteConnection), } /// Custom connection manager that implements manual connection establishment pub struct DbConnManager { database_url: String, } impl DbConnManager { pub fn new(database_url: &str) -> Self { Self { database_url: database_url.to_string(), } } fn establish_connection(&self) -> Result { match DbConnType::from_url(&self.database_url) { #[cfg(mysql)] Ok(DbConnType::Mysql) => { let conn = diesel::mysql::MysqlConnection::establish(&self.database_url)?; Ok(DbConnInner::Mysql(conn)) } #[cfg(postgresql)] Ok(DbConnType::Postgresql) => { let conn = diesel::pg::PgConnection::establish(&self.database_url)?; Ok(DbConnInner::Postgresql(conn)) } #[cfg(sqlite)] Ok(DbConnType::Sqlite) => { let conn = diesel::sqlite::SqliteConnection::establish(&self.database_url)?; Ok(DbConnInner::Sqlite(conn)) } Err(e) => Err(diesel::r2d2::Error::ConnectionError(diesel::ConnectionError::InvalidConnectionUrl( format!("Unable to estabilsh a connection: {e:?}"), ))), } } } impl diesel::r2d2::ManageConnection for DbConnManager { type Connection = DbConnInner; type Error = diesel::r2d2::Error; fn connect(&self) -> Result { self.establish_connection() } fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { use diesel::r2d2::R2D2Connection; conn.ping().map_err(diesel::r2d2::Error::QueryError) } fn has_broken(&self, conn: &mut Self::Connection) -> bool { use diesel::r2d2::R2D2Connection; conn.is_broken() } } #[derive(Eq, PartialEq)] pub enum DbConnType { #[cfg(mysql)] Mysql, #[cfg(postgresql)] Postgresql, #[cfg(sqlite)] Sqlite, } pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); pub struct DbConn { conn: Arc>>>, permit: Option, } #[derive(Debug)] pub struct DbConnOptions { pub init_stmts: String, } impl CustomizeConnection for DbConnOptions { fn on_acquire(&self, conn: &mut DbConnInner) -> Result<(), diesel::r2d2::Error> { if !self.init_stmts.is_empty() { conn.batch_execute(&self.init_stmts).map_err(diesel::r2d2::Error::QueryError)?; } Ok(()) } } #[derive(Clone)] pub struct DbPool { // This is an 'Option' so that we can drop the pool in a 'spawn_blocking'. pool: Option>, semaphore: Arc, } impl Drop for DbConn { fn drop(&mut self) { let conn = Arc::clone(&self.conn); let permit = self.permit.take(); // Since connection can't be on the stack in an async fn during an // await, we have to spawn a new blocking-safe thread... tokio::task::spawn_blocking(move || { // And then re-enter the runtime to wait on the async mutex, but in a blocking fashion. let mut conn = tokio::runtime::Handle::current().block_on(conn.lock_owned()); if let Some(conn) = conn.take() { drop(conn); } // Drop permit after the connection is dropped drop(permit); }); } } impl Drop for DbPool { fn drop(&mut self) { let pool = self.pool.take(); // Only use spawn_blocking if the Tokio runtime is still available // Otherwise the pool will be dropped on the current thread if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn_blocking(move || drop(pool)); } } } impl DbPool { // For the given database URL, guess its type, run migrations, create pool, and return it pub fn from_config() -> Result { let db_url = CONFIG.database_url(); let conn_type = DbConnType::from_url(&db_url)?; // Only set the default instrumentation if the log level is specifically set to either warn, info or debug if log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Warn) || log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Info) || log_enabled!(target: "vaultwarden::db::query_logger", log::Level::Debug) { drop(diesel::connection::set_default_instrumentation(query_logger::simple_logger)); } match conn_type { #[cfg(mysql)] DbConnType::Mysql => { mysql_migrations::run_migrations(&db_url)?; } #[cfg(postgresql)] DbConnType::Postgresql => { postgresql_migrations::run_migrations(&db_url)?; } #[cfg(sqlite)] DbConnType::Sqlite => { sqlite_migrations::run_migrations(&db_url)?; } } let max_conns = CONFIG.database_max_conns(); let manager = DbConnManager::new(&db_url); let pool = Pool::builder() .max_size(max_conns) .min_idle(Some(CONFIG.database_min_conns())) .idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) .connection_timeout(Duration::from_secs(CONFIG.database_timeout())) .connection_customizer(Box::new(DbConnOptions { init_stmts: conn_type.get_init_stmts(), })) .build(manager) .map_res("Failed to create pool")?; // Set a global to determine the database more easily throughout the rest of the code if ACTIVE_DB_TYPE.set(conn_type).is_err() { error!("Tried to set the active database connection type more than once.") } Ok(DbPool { pool: Some(pool), semaphore: Arc::new(Semaphore::new(max_conns as usize)), }) } // Get a connection from the pool pub async fn get(&self) -> Result { let duration = Duration::from_secs(CONFIG.database_timeout()); let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { Ok(p) => p.expect("Semaphore should be open"), Err(_) => { err!("Timeout waiting for database connection"); } }; let p = self.pool.as_ref().expect("DbPool.pool should always be Some()"); let pool = p.clone(); let c = run_blocking(move || pool.get_timeout(duration)).await.map_res("Error retrieving connection from pool")?; Ok(DbConn { conn: Arc::new(Mutex::new(Some(c))), permit: Some(permit), }) } } impl DbConnType { pub fn from_url(url: &str) -> Result { // Mysql if url.len() > 6 && &url[..6] == "mysql:" { #[cfg(mysql)] return Ok(DbConnType::Mysql); #[cfg(not(mysql))] err!("`DATABASE_URL` is a MySQL URL, but the 'mysql' feature is not enabled") // Postgresql } else if url.len() > 11 && (&url[..11] == "postgresql:" || &url[..9] == "postgres:") { #[cfg(postgresql)] return Ok(DbConnType::Postgresql); #[cfg(not(postgresql))] err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled") //Sqlite } else { #[cfg(sqlite)] return Ok(DbConnType::Sqlite); #[cfg(not(sqlite))] err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled") } } pub fn get_init_stmts(&self) -> String { let init_stmts = CONFIG.database_conn_init(); if !init_stmts.is_empty() { init_stmts } else { self.default_init_stmts() } } pub fn default_init_stmts(&self) -> String { match self { #[cfg(mysql)] Self::Mysql => String::new(), #[cfg(postgresql)] Self::Postgresql => String::new(), #[cfg(sqlite)] Self::Sqlite => "PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;".to_string(), } } } impl DbConn { pub async fn run(&self, f: F) -> R where F: FnOnce(&mut DbConnInner) -> R + Send, R: Send + 'static, { let conn = Arc::clone(&self.conn); let mut conn = conn.lock_owned().await; let conn = conn.as_mut().expect("Internal invariant broken: self.conn is Some"); // Run blocking can't be used due to the 'static limitation, use block_in_place instead tokio::task::block_in_place(move || f(conn)) } } #[macro_export] macro_rules! db_run { ( $conn:ident: $body:block ) => { $conn.run(move |$conn| $body).await }; ( $conn:ident: $( $($db:ident),+ $body:block )+ ) => { $conn.run(move |$conn| { match $conn { $($( #[cfg($db)] pastey::paste!(&mut $crate::db::DbConnInner::[<$db:camel>](ref mut $conn)) => { $body }, )+)+} }).await }; } // Write all ToSql and FromSql given a serializable/deserializable type. #[macro_export] macro_rules! impl_FromToSqlText { ($name:ty) => { #[cfg(mysql)] impl ToSql for $name { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::mysql::Mysql>) -> diesel::serialize::Result { serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into) } } #[cfg(postgresql)] impl ToSql for $name { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result { serde_json::to_writer(out, self).map(|_| diesel::serialize::IsNull::No).map_err(Into::into) } } #[cfg(sqlite)] impl ToSql for $name { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, diesel::sqlite::Sqlite>) -> diesel::serialize::Result { serde_json::to_string(self).map_err(Into::into).map(|str| { out.set_value(str); diesel::serialize::IsNull::No }) } } impl FromSql for $name where String: FromSql, { fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result { >::from_sql(bytes) .and_then(|str| serde_json::from_str(&str).map_err(Into::into)) } } }; } pub mod schema; // Reexport the models, needs to be after the macros are defined so it can access them pub mod models; /// Creates a back-up of the sqlite database /// MySQL/MariaDB and PostgreSQL are not supported. #[cfg(sqlite)] pub fn backup_sqlite() -> Result { use diesel::Connection; use std::{fs::File, io::Write}; let db_url = CONFIG.database_url(); if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) { // Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't // This way we can set a readonly flag on the opening mode without issues. let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?; let db_path = std::path::Path::new(&db_url).parent().unwrap(); let backup_file = db_path .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) .to_string_lossy() .into_owned(); match File::create(backup_file.clone()) { Ok(mut f) => { let serialized_db = conn.serialize_database_to_buffer(); f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup"); Ok(backup_file) } Err(e) => { err_silent!(format!("Unable to save SQLite backup: {e:?}")) } } } else { err_silent!("The database type is not SQLite. Backups only works for SQLite databases") } } #[cfg(not(sqlite))] pub fn backup_sqlite() -> Result { err_silent!("The database type is not SQLite. Backups only works for SQLite databases") } /// Get the SQL Server version pub async fn get_sql_server_version(conn: &DbConn) -> String { db_run! { conn: postgresql,mysql { diesel::select(diesel::dsl::sql::("version();")) .get_result::(conn) .unwrap_or_else(|_| "Unknown".to_string()) } sqlite { diesel::select(diesel::dsl::sql::("sqlite_version();")) .get_result::(conn) .unwrap_or_else(|_| "Unknown".to_string()) } } } /// Attempts to retrieve a single connection from the managed database pool. If /// no pool is currently managed, fails with an `InternalServerError` status. If /// no connections are available, fails with a `ServiceUnavailable` status. #[rocket::async_trait] impl<'r> FromRequest<'r> for DbConn { type Error = (); async fn from_request(request: &'r Request<'_>) -> Outcome { match request.rocket().state::() { Some(p) => match p.get().await { Ok(dbconn) => Outcome::Success(dbconn), _ => Outcome::Error((Status::ServiceUnavailable, ())), }, None => Outcome::Error((Status::InternalServerError, ())), } } } // Embed the migrations from the migrations folder into the application // This way, the program automatically migrates the database to the latest version // https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html #[cfg(sqlite)] mod sqlite_migrations { use diesel::{Connection, RunQueryDsl}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Establish a connection to the sqlite database (this will create a new one, if it does // not exist, and exit if there is an error). let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?; // Run the migrations after successfully establishing a connection // Disable Foreign Key Checks during migration // Scoped to a connection. diesel::sql_query("PRAGMA foreign_keys = OFF") .execute(&mut connection) .expect("Failed to disable Foreign Key Checks during migrations"); // Turn on WAL in SQLite if crate::CONFIG.enable_db_wal() { diesel::sql_query("PRAGMA journal_mode=wal").execute(&mut connection).expect("Failed to turn on WAL"); } connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); Ok(()) } } #[cfg(mysql)] mod mysql_migrations { use diesel::{Connection, RunQueryDsl}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Make sure the database is up to date (create if it doesn't exist, or run the migrations) let mut connection = diesel::mysql::MysqlConnection::establish(db_url)?; // Disable Foreign Key Checks during migration // Scoped to a connection/session. diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0") .execute(&mut connection) .expect("Failed to disable Foreign Key Checks during migrations"); connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); Ok(()) } } #[cfg(postgresql)] mod postgresql_migrations { use diesel::Connection; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql"); pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Make sure the database is up to date (create if it doesn't exist, or run the migrations) let mut connection = diesel::pg::PgConnection::establish(db_url)?; connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); Ok(()) } } ================================================ FILE: src/db/models/attachment.rs ================================================ use bigdecimal::{BigDecimal, ToPrimitive}; use derive_more::{AsRef, Deref, Display}; use diesel::prelude::*; use serde_json::Value; use std::time::Duration; use super::{CipherId, OrganizationId, UserId}; use crate::db::schema::{attachments, ciphers}; use crate::{config::PathType, CONFIG}; use macros::IdFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = attachments)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(id))] pub struct Attachment { pub id: AttachmentId, pub cipher_uuid: CipherId, pub file_name: String, // encrypted pub file_size: i64, pub akey: Option, } /// Local methods impl Attachment { pub const fn new( id: AttachmentId, cipher_uuid: CipherId, file_name: String, file_size: i64, akey: Option, ) -> Self { Self { id, cipher_uuid, file_name, file_size, akey, } } pub fn get_file_path(&self) -> String { format!("{}/{}", self.cipher_uuid, self.id) } pub async fn get_url(&self, host: &str) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) } else { Ok(operator.presign_read(&self.get_file_path(), Duration::from_secs(5 * 60)).await?.uri().to_string()) } } pub async fn to_json(&self, host: &str) -> Result { Ok(json!({ "id": self.id, "url": self.get_url(host).await?, "fileName": self.file_name, "size": self.file_size.to_string(), "sizeName": crate::util::get_display_size(self.file_size), "key": self.akey, "object": "attachment" })) } } use crate::auth::{encode_jwt, generate_file_download_claims}; use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Attachment { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(attachments::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(attachments::table) .filter(attachments::id.eq(&self.id)) .set(self) .execute(conn) .map_res("Error saving attachment") } Err(e) => Err(e.into()), }.map_res("Error saving attachment") } postgresql { diesel::insert_into(attachments::table) .values(self) .on_conflict(attachments::id) .do_update() .set(self) .execute(conn) .map_res("Error saving attachment") } } } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { crate::util::retry(|| diesel::delete(attachments::table.filter(attachments::id.eq(&self.id))) .execute(conn), 10, ) .map(|_| ()) .map_res("Error deleting attachment") }}?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; let file_path = self.get_file_path(); if let Err(e) = operator.delete(&file_path).await { if e.kind() == opendal::ErrorKind::NotFound { debug!("File '{file_path}' already deleted."); } else { return Err(e.into()); } } Ok(()) } pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { for attachment in Attachment::find_by_cipher(cipher_uuid, conn).await { attachment.delete(conn).await?; } Ok(()) } pub async fn find_by_id(id: &AttachmentId, conn: &DbConn) -> Option { db_run! { conn: { attachments::table .filter(attachments::id.eq(id.to_lowercase())) .first::(conn) .ok() }} } pub async fn find_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> Vec { db_run! { conn: { attachments::table .filter(attachments::cipher_uuid.eq(cipher_uuid)) .load::(conn) .expect("Error loading attachments") }} } pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) .select(diesel::dsl::sum(attachments::file_size)) .first(conn) .expect("Error loading user attachment total size"); match result.map(|r| r.to_i64()) { Some(Some(r)) => r, Some(None) => i64::MAX, None => 0 } }} } pub async fn count_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) .count() .first(conn) .unwrap_or(0) }} } pub async fn size_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { let result: Option = attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::organization_uuid.eq(org_uuid)) .select(diesel::dsl::sum(attachments::file_size)) .first(conn) .expect("Error loading user attachment total size"); match result.map(|r| r.to_i64()) { Some(Some(r)) => r, Some(None) => i64::MAX, None => 0 } }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::organization_uuid.eq(org_uuid)) .count() .first(conn) .unwrap_or(0) }} } // This will return all attachments linked to the user or org // There is no filtering done here if the user actually has access! // It is used to speed up the sync process, and the matching is done in a different part. pub async fn find_all_by_user_and_orgs( user_uuid: &UserId, org_uuids: &Vec, conn: &DbConn, ) -> Vec { db_run! { conn: { attachments::table .left_join(ciphers::table.on(ciphers::uuid.eq(attachments::cipher_uuid))) .filter(ciphers::user_uuid.eq(user_uuid)) .or_filter(ciphers::organization_uuid.eq_any(org_uuids)) .select(attachments::all_columns) .load::(conn) .expect("Error loading attachments") }} } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, )] pub struct AttachmentId(pub String); ================================================ FILE: src/db/models/auth_request.rs ================================================ use super::{DeviceId, OrganizationId, UserId}; use crate::db::schema::auth_requests; use crate::{crypto::ct_eq, util::format_date}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use macros::UuidFromParam; use serde_json::Value; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] #[diesel(table_name = auth_requests)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct AuthRequest { pub uuid: AuthRequestId, pub user_uuid: UserId, pub organization_uuid: Option, pub request_device_identifier: DeviceId, pub device_type: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub request_ip: String, pub response_device_id: Option, pub access_code: String, pub public_key: String, pub enc_key: Option, pub master_password_hash: Option, pub approved: Option, pub creation_date: NaiveDateTime, pub response_date: Option, pub authentication_date: Option, } impl AuthRequest { pub fn new( user_uuid: UserId, request_device_identifier: DeviceId, device_type: i32, request_ip: String, access_code: String, public_key: String, ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: AuthRequestId(crate::util::get_uuid()), user_uuid, organization_uuid: None, request_device_identifier, device_type, request_ip, response_device_id: None, access_code, public_key, enc_key: None, master_password_hash: None, approved: None, creation_date: now, response_date: None, authentication_date: None, } } pub fn to_json_for_pending_device(&self) -> Value { json!({ "id": self.uuid, "creationDate": format_date(&self.creation_date), }) } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; impl AuthRequest { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(auth_requests::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(auth_requests::table) .filter(auth_requests::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error auth_request") } Err(e) => Err(e.into()), }.map_res("Error auth_request") } postgresql { diesel::insert_into(auth_requests::table) .values(&*self) .on_conflict(auth_requests::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving auth_request") } } } pub async fn find_by_uuid(uuid: &AuthRequestId, conn: &DbConn) -> Option { db_run! { conn: { auth_requests::table .filter(auth_requests::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_user(uuid: &AuthRequestId, user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { auth_requests::table .filter(auth_requests::uuid.eq(uuid)) .filter(auth_requests::user_uuid.eq(user_uuid)) .first::(conn) .ok() }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { auth_requests::table .filter(auth_requests::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading auth_requests") }} } pub async fn find_by_user_and_requested_device( user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn, ) -> Option { db_run! { conn: { auth_requests::table .filter(auth_requests::user_uuid.eq(user_uuid)) .filter(auth_requests::request_device_identifier.eq(device_uuid)) .filter(auth_requests::approved.is_null()) .order_by(auth_requests::creation_date.desc()) .first::(conn) .ok() }} } pub async fn find_created_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { db_run! { conn: { auth_requests::table .filter(auth_requests::creation_date.lt(dt)) .load::(conn) .expect("Error loading auth_requests") }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting auth request") }} } pub fn check_access_code(&self, access_code: &str) -> bool { ct_eq(&self.access_code, access_code) } pub async fn purge_expired_auth_requests(conn: &DbConn) { // delete auth requests older than 15 minutes which is functionally equivalent to upstream: // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); for auth_request in Self::find_created_before(&expiry_time, conn).await { auth_request.delete(conn).await.ok(); } } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct AuthRequestId(String); ================================================ FILE: src/db/models/cipher.rs ================================================ use crate::db::schema::{ ciphers, ciphers_collections, collections, collections_groups, folders, folders_ciphers, groups, groups_users, users_collections, users_organizations, }; use crate::util::LowerCase; use crate::CONFIG; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; use super::{ Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, MembershipStatus, MembershipType, OrganizationId, User, UserId, }; use crate::api::core::{CipherData, CipherSyncData, CipherSyncType}; use macros::UuidFromParam; use std::borrow::Cow; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = ciphers)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Cipher { pub uuid: CipherId, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub user_uuid: Option, pub organization_uuid: Option, pub key: Option, /* Login = 1, SecureNote = 2, Card = 3, Identity = 4, SshKey = 5 */ pub atype: i32, pub name: String, pub notes: Option, pub fields: Option, pub data: String, pub password_history: Option, pub deleted_at: Option, pub reprompt: Option, } pub enum RepromptType { None = 0, Password = 1, } /// Local methods impl Cipher { pub fn new(atype: i32, name: String) -> Self { let now = Utc::now().naive_utc(); Self { uuid: CipherId(crate::util::get_uuid()), created_at: now, updated_at: now, user_uuid: None, organization_uuid: None, key: None, atype, name, notes: None, fields: None, data: String::new(), password_history: None, deleted_at: None, reprompt: None, } } pub fn validate_cipher_data(cipher_data: &[CipherData]) -> EmptyResult { let mut validation_errors = serde_json::Map::new(); let max_note_size = CONFIG._max_note_size(); let max_note_size_msg = format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."); for (index, cipher) in cipher_data.iter().enumerate() { // Validate the note size and if it is exceeded return a warning if let Some(note) = &cipher.notes { if note.len() > max_note_size { validation_errors .insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap()); } } // Validate the password history if it contains `null` values and if so, return a warning if let Some(Value::Array(password_history)) = &cipher.password_history { for pwh in password_history { if let Value::Object(pwo) = pwh { if pwo.get("password").is_some_and(|p| !p.is_string()) { validation_errors.insert( format!("Ciphers[{index}].Notes"), serde_json::to_value([ "The password history contains a `null` value. Only strings are allowed.", ]) .unwrap(), ); break; } } } } } if !validation_errors.is_empty() { let err_json = json!({ "message": "The model state is invalid.", "validationErrors" : validation_errors, "object": "error" }); err_json!(err_json, "Import validation errors") } else { Ok(()) } } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Cipher { pub async fn to_json( &self, host: &str, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, conn: &DbConn, ) -> Result { use crate::util::{format_date, validate_and_format_date}; let mut attachments_json: Value = Value::Null; if let Some(cipher_sync_data) = cipher_sync_data { if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid) { if !attachments.is_empty() { let mut attachments_json_vec = vec![]; for attachment in attachments { attachments_json_vec.push(attachment.to_json(host).await?); } attachments_json = Value::Array(attachments_json_vec); } } } else { let attachments = Attachment::find_by_cipher(&self.uuid, conn).await; if !attachments.is_empty() { let mut attachments_json_vec = vec![]; for attachment in attachments { attachments_json_vec.push(attachment.to_json(host).await?); } attachments_json = Value::Array(attachments_json_vec); } } // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { match self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { Some((ro, hp, mn)) => (ro, hp, mn), None => { error!("Cipher ownership assertion failure"); (true, true, false) } } } else { (false, false, false) }; let fields_json: Vec<_> = self .fields .as_ref() .and_then(|s| { serde_json::from_str::>>(s) .inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid)) .ok() }) .map(|d| { d.into_iter() .map(|mut f| { // Check if the `type` key is a number, strings break some clients // The fallback type is the hidden type `1`. this should prevent accidental data disclosure // If not try to convert the string value to a number and fallback to `1` // If it is both not a number and not a string, fallback to `1` match f.data.get("type") { Some(t) if t.is_number() => {} Some(t) if t.is_string() => { let type_num = &t.as_str().unwrap_or("1").parse::().unwrap_or(1); f.data["type"] = json!(type_num); } _ => { f.data["type"] = json!(1); } } f.data }) .collect() }) .unwrap_or_default(); let password_history_json: Vec<_> = self .password_history .as_ref() .and_then(|s| { serde_json::from_str::>>(s) .inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid)) .ok() }) .map(|d| { // Check every password history item if they are valid and return it. // If a password field has the type `null` skip it, it breaks newer Bitwarden clients // A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used d.into_iter() .filter_map(|d| match d.data.get("password") { Some(p) if p.is_string() => Some(d.data), _ => None, }) .map(|mut d| match d.get("lastUsedDate").and_then(|l| l.as_str()) { Some(l) => { d["lastUsedDate"] = json!(validate_and_format_date(l)); d } _ => { d["lastUsedDate"] = json!("1970-01-01T00:00:00.000000Z"); d } }) .collect() }) .unwrap_or_default(); // Get the type_data or a default to an empty json object '{}'. // If not passing an empty object, mobile clients will crash. let mut type_data_json = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_else(|_| { warn!("Error parsing data field for {}", self.uuid); Value::Object(serde_json::Map::new()) }); // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream // Set the first element of the Uris array as Uri, this is needed several (mobile) clients. if self.atype == 1 { // Upstream always has an `uri` key/value type_data_json["uri"] = Value::Null; if let Some(uris) = type_data_json["uris"].as_array_mut() { if !uris.is_empty() { // Fix uri match values first, they are only allowed to be a number or null // If it is a string, convert it to an int or null if that fails for uri in &mut *uris { if uri["match"].is_string() { let match_value = match uri["match"].as_str().unwrap_or_default().parse::() { Ok(n) => json!(n), _ => Value::Null, }; uri["match"] = match_value; } } type_data_json["uri"] = uris[0]["uri"].clone(); } } // Check if `passwordRevisionDate` is a valid date, else convert it if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() { type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision)); } } // Fix secure note issues when data is invalid // This breaks at least the native mobile clients if self.atype == 2 { match type_data_json { Value::Object(ref t) if t.get("type").is_some_and(|t| t.is_number()) => {} _ => { type_data_json = json!({"type": 0}); } } } // Fix invalid SSH Entries // This breaks at least the native mobile client if invalid // The only way to fix this is by setting type_data_json to `null` // Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it if self.atype == 5 && (type_data_json["keyFingerprint"].as_str().is_none_or(|v| v.is_empty()) || type_data_json["privateKey"].as_str().is_none_or(|v| v.is_empty()) || type_data_json["publicKey"].as_str().is_none_or(|v| v.is_empty())) { warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid); type_data_json = Value::Null; } // Clone the type_data and add some default value. let mut data_json = type_data_json.clone(); // NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream // data_json should always contain the following keys with every atype data_json["fields"] = json!(fields_json); data_json["name"] = json!(self.name); data_json["notes"] = json!(self.notes); data_json["passwordHistory"] = Value::Array(password_history_json.clone()); let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { Cow::from(cipher_collections) } else { Cow::from(Vec::with_capacity(0)) } } else { Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) }; // There are three types of cipher response models in upstream // Bitwarden: "cipherMini", "cipher", and "cipherDetails" (in order // of increasing level of detail). vaultwarden currently only // supports the "cipherDetails" type, though it seems like the // Bitwarden clients will ignore extra fields. // // Ref: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Vault/Models/Response/CipherResponseModel.cs#L14 let mut json_object = json!({ "object": "cipherDetails", "id": self.uuid, "type": self.atype, "creationDate": format_date(&self.created_at), "revisionDate": format_date(&self.updated_at), "deletedDate": self.deleted_at.map_or(Value::Null, |d| Value::String(format_date(&d))), "reprompt": self.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32).unwrap_or(RepromptType::None as i32), "organizationId": self.organization_uuid, "key": self.key, "attachments": attachments_json, // We have UseTotp set to true by default within the Organization model. // This variable together with UsersGetPremium is used to show or hide the TOTP counter. "organizationUseTotp": true, // This field is specific to the cipherDetails type. "collectionIds": collection_ids, "name": self.name, "notes": self.notes, "fields": fields_json, "data": data_json, "passwordHistory": password_history_json, // All Cipher types are included by default as null, but only the matching one will be populated "login": null, "secureNote": null, "card": null, "identity": null, "sshKey": null, }); // These values are only needed for user/default syncs // Not during an organizational sync like `get_org_details` // Skip adding these fields in that case if sync_type == CipherSyncType::User { json_object["folderId"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { cipher_sync_data.cipher_folders.get(&self.uuid).cloned() } else { self.get_folder_uuid(user_uuid, conn).await }); json_object["favorite"] = json!(if let Some(cipher_sync_data) = cipher_sync_data { cipher_sync_data.cipher_favorites.contains(&self.uuid) } else { self.is_favorite(user_uuid, conn).await }); // These values are true by default, but can be false if the // cipher belongs to a collection or group where the org owner has enabled // the "Read Only" or "Hide Passwords" restrictions for the user. json_object["edit"] = json!(!read_only); json_object["viewPassword"] = json!(!hide_passwords); // The new key used by clients since v2025.6.0 json_object["permissions"] = json!({ "delete": !read_only, "restore": !read_only, }); } let key = match self.atype { 1 => "login", 2 => "secureNote", 3 => "card", 4 => "identity", 5 => "sshKey", _ => panic!("Wrong type"), }; json_object[key] = type_data_json; Ok(json_object) } pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); match self.user_uuid { Some(ref user_uuid) => { User::update_uuid_revision(user_uuid, conn).await; user_uuids.push(user_uuid.clone()) } None => { // Belongs to Organization, need to update affected users if let Some(ref org_uuid) = self.organization_uuid { // users having access to the collection let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await; if CONFIG.org_groups_enabled() { // members of a group having access to the collection let group_users = Membership::find_by_cipher_and_org_with_group(&self.uuid, org_uuid, conn).await; collection_users.extend(group_users); } for member in collection_users { User::update_uuid_revision(&member.user_uuid, conn).await; user_uuids.push(member.user_uuid.clone()) } } } }; user_uuids } pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(ciphers::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(ciphers::table) .filter(ciphers::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error saving cipher") } Err(e) => Err(e.into()), }.map_res("Error saving cipher") } postgresql { diesel::insert_into(ciphers::table) .values(&*self) .on_conflict(ciphers::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving cipher") } } } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; FolderCipher::delete_all_by_cipher(&self.uuid, conn).await?; CollectionCipher::delete_all_by_cipher(&self.uuid, conn).await?; Attachment::delete_all_by_cipher(&self.uuid, conn).await?; Favorite::delete_all_by_cipher(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(ciphers::table.filter(ciphers::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting cipher") }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { // TODO: Optimize this by executing a DELETE directly on the database, instead of first fetching. for cipher in Self::find_by_org(org_uuid, conn).await { cipher.delete(conn).await?; } Ok(()) } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for cipher in Self::find_owned_by_user(user_uuid, conn).await { cipher.delete(conn).await?; } Ok(()) } /// Purge all ciphers that are old enough to be auto-deleted. pub async fn purge_trash(conn: &DbConn) { if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { let now = Utc::now().naive_utc(); let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); for cipher in Self::find_deleted_before(&dt, conn).await { cipher.delete(conn).await.ok(); } } } pub async fn move_to_folder( &self, folder_uuid: Option, user_uuid: &UserId, conn: &DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) { // No changes (None, None) => Ok(()), (Some(ref old_folder), Some(ref new_folder)) if old_folder == new_folder => Ok(()), // Add to folder (None, Some(new_folder)) => FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await, // Remove from folder (Some(old_folder), None) => { match FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { Some(old_folder) => old_folder.delete(conn).await, None => err!("Couldn't move from previous folder"), } } // Move to another folder (Some(old_folder), Some(new_folder)) => { if let Some(old_folder) = FolderCipher::find_by_folder_and_cipher(&old_folder, &self.uuid, conn).await { old_folder.delete(conn).await?; } FolderCipher::new(new_folder, self.uuid.clone()).save(conn).await } } } /// Returns whether this cipher is directly owned by the user. pub fn is_owned_by_user(&self, user_uuid: &UserId) -> bool { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } /// Returns whether this cipher is owned by an org in which the user has full access. async fn is_in_full_access_org( &self, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> bool { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { return cached_member.has_full_access(); } } else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await { return member.has_full_access(); } } false } /// Returns whether this cipher is owned by an group in which the user has full access. async fn is_in_full_access_group( &self, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> bool { if !CONFIG.org_groups_enabled() { return false; } if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { return cipher_sync_data.user_group_full_access_for_organizations.contains(org_uuid); } else { return Group::is_in_full_access_group(user_uuid, org_uuid, conn).await; } } 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, manage) represents /// the access restrictions. pub async fn get_access_restrictions( &self, user_uuid: &UserId, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { // Check whether this cipher is directly owned by the user, or is in // a collection that the user has full access to. If so, there are no // access restrictions. if self.is_owned_by_user(user_uuid) || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { return Some((false, false, true)); } let rows = if let Some(cipher_sync_data) = cipher_sync_data { let mut rows: Vec<(bool, bool, bool)> = Vec::new(); if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { for collection in collections { // User permissions if let Some(cu) = cipher_sync_data.user_collections.get(collection) { rows.push((cu.read_only, cu.hide_passwords, cu.manage)); // Group permissions } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) { rows.push((cg.read_only, cg.hide_passwords, cg.manage)); } } } rows } else { let user_permissions = self.get_user_collections_access_flags(user_uuid, conn).await; if !user_permissions.is_empty() { user_permissions } else { self.get_group_collections_access_flags(user_uuid, conn).await } }; if rows.is_empty() { // This cipher isn't in any collections accessible to the user. return None; } // A cipher can be in multiple collections with inconsistent access flags. // Also, user permission overrule group permissions // and only user permissions are returned by the code above. // // 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. For a flag to be in effect for a cipher, upstream // requires all collections the cipher is in to have that flag set. // Therefore, we do a boolean AND 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 min() or bool_and() function on // booleans and this behavior isn't portable anyway. // // The only exception is for the `manage` flag, that needs a boolean OR! let mut read_only = true; let mut hide_passwords = true; let mut manage = false; for (ro, hp, mn) in rows.iter() { read_only &= ro; hide_passwords &= hp; manage |= mn; } Some((read_only, hide_passwords, manage)) } async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { db_run! { conn: { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) .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, users_collections::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting user access restrictions") }} } async fn get_group_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { if !CONFIG.org_groups_enabled() { return Vec::new(); } db_run! { conn: { ciphers::table .filter(ciphers::uuid.eq(&self.uuid)) .inner_join(ciphers_collections::table.on( ciphers::uuid.eq(ciphers_collections::cipher_uuid) )) .inner_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) )) .inner_join(groups_users::table.on( groups_users::groups_uuid.eq(collections_groups::groups_uuid) )) .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .load::<(bool, bool, bool)>(conn) .expect("Error getting group access restrictions") }} } pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { match self.get_access_restrictions(user_uuid, None, conn).await { Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } } // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { match self.get_access_restrictions(user_uuid, None, conn).await { Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, None => false, } } pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { self.get_access_restrictions(user_uuid, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. pub async fn is_favorite(&self, user_uuid: &UserId, conn: &DbConn) -> bool { Favorite::is_favorite(&self.uuid, user_uuid, conn).await } // Sets whether this cipher is a favorite of the specified user. pub async fn set_favorite(&self, favorite: Option, user_uuid: &UserId, conn: &DbConn) -> EmptyResult { match favorite { None => Ok(()), // No change requested. Some(status) => Favorite::set_favorite(status, &self.uuid, user_uuid, conn).await, } } pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(&user_uuid)) .filter(folders_ciphers::cipher_uuid.eq(&self.uuid)) .select(folders_ciphers::folder_uuid) .first::(conn) .ok() }} } pub async fn find_by_uuid(uuid: &CipherId, conn: &DbConn) -> Option { db_run! { conn: { ciphers::table .filter(ciphers::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_org( cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { db_run! { conn: { ciphers::table .filter(ciphers::uuid.eq(cipher_uuid)) .filter(ciphers::organization_uuid.eq(org_uuid)) .first::(conn) .ok() }} } // Find all ciphers accessible or visible to the specified user. // // "Accessible" means the user has read access to the cipher, either via // direct ownership, collection or via group access. // // "Visible" usually means the same as accessible, except when an org // owner/admin sets their account or group to have access to only selected // collections in the org (presumably because they aren't interested in // the other collections in the org). In this case, if `visible_only` is // true, then the non-interesting ciphers will not be returned. As a // result, those ciphers will not appear in "My Vault" for the org // owner/admin, but they can still be accessed via the org vault view. pub async fn find_by_user( user_uuid: &UserId, visible_only: bool, cipher_uuids: &Vec, conn: &DbConn, ) -> Vec { if CONFIG.org_groups_enabled() { db_run! { conn: { let mut query = ciphers::table .left_join(ciphers_collections::table.on( ciphers::uuid.eq(ciphers_collections::cipher_uuid) )) .left_join(users_organizations::table.on( ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) .and(users_organizations::user_uuid.eq(user_uuid)) .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) )) .left_join(users_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) // Ensure that users_collections::user_uuid is NULL for unconfirmed users. .and(users_organizations::user_uuid.eq(users_collections::user_uuid)) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( collections_groups::groups_uuid.eq(groups::uuid) ) )) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(groups::access_all.eq(true)) // Access via groups .or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups .into_boxed(); if !visible_only { query = query.or_filter( users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner ); } // Only filter for one specific cipher if !cipher_uuids.is_empty() { query = query.filter( ciphers::uuid.eq_any(cipher_uuids) ); } query .select(ciphers::all_columns) .distinct() .load::(conn) .expect("Error loading ciphers") }} } else { db_run! { conn: { let mut query = ciphers::table .left_join(ciphers_collections::table.on( ciphers::uuid.eq(ciphers_collections::cipher_uuid) )) .left_join(users_organizations::table.on( ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) .and(users_organizations::user_uuid.eq(user_uuid)) .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) )) .left_join(users_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) // Ensure that users_collections::user_uuid is NULL for unconfirmed users. .and(users_organizations::user_uuid.eq(users_collections::user_uuid)) )) .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .into_boxed(); if !visible_only { query = query.or_filter( users_organizations::atype.le(MembershipType::Admin as i32) // Org admin/owner ); } // Only filter for one specific cipher if !cipher_uuids.is_empty() { query = query.filter( ciphers::uuid.eq_any(cipher_uuids) ); } query .select(ciphers::all_columns) .distinct() .load::(conn) .expect("Error loading ciphers") }} } } // Find all ciphers visible to the specified user. pub async fn find_by_user_visible(user_uuid: &UserId, conn: &DbConn) -> Vec { Self::find_by_user(user_uuid, true, &vec![], conn).await } pub async fn find_by_user_and_ciphers( user_uuid: &UserId, cipher_uuids: &Vec, conn: &DbConn, ) -> Vec { Self::find_by_user(user_uuid, true, cipher_uuids, conn).await } pub async fn find_by_user_and_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> Option { Self::find_by_user(user_uuid, true, &vec![cipher_uuid.clone()], conn).await.pop() } // Find all ciphers directly owned by the specified user. pub async fn find_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { ciphers::table .filter( ciphers::user_uuid.eq(user_uuid) .and(ciphers::organization_uuid.is_null()) ) .load::(conn) .expect("Error loading ciphers") }} } pub async fn count_owned_by_user(user_uuid: &UserId, conn: &DbConn) -> i64 { db_run! { conn: { ciphers::table .filter(ciphers::user_uuid.eq(user_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading ciphers") }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { ciphers::table .filter(ciphers::organization_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { db_run! { conn: { folders_ciphers::table.inner_join(ciphers::table) .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .select(ciphers::all_columns) .load::(conn) .expect("Error loading ciphers") }} } /// Find all ciphers that were deleted before the specified datetime. pub async fn find_deleted_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { db_run! { conn: { ciphers::table .filter(ciphers::deleted_at.lt(dt)) .load::(conn) .expect("Error loading ciphers") }} } pub async fn get_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .left_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone())) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid)) )) .filter(users_organizations::access_all.eq(true) // User has access all .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) .or(groups::access_all.eq(true)) // Access via groups .or(collections_groups::collections_uuid.is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() }} } else { db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone())) )) .filter(users_organizations::access_all.eq(true) // User has access all .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() }} } } pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .left_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone())) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) .and(collections_groups::groups_uuid.eq(groups::uuid)) )) .filter(users_organizations::access_all.eq(true) // User has access all .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) .or(groups::access_all.eq(true)) // Access via groups .or(collections_groups::collections_uuid.is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() }} } else { db_run! { conn: { ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(&self.uuid)) .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid) .and(users_collections::user_uuid.eq(user_uuid.clone())) )) .filter(users_organizations::access_all.eq(true) // User has access all .or(users_collections::user_uuid.eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) .or(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) .unwrap_or_default() }} } } /// Return a Vec with (cipher_uuid, collection_uuid) /// This is used during a full sync so we only need one query for all collections accessible. pub async fn get_collections_with_cipher_by_user( user_uuid: UserId, conn: &DbConn, ) -> Vec<(CipherId, CollectionId)> { db_run! { conn: { ciphers_collections::table .inner_join(collections::table.on( collections::uuid.eq(ciphers_collections::collection_uuid) )) .inner_join(users_organizations::table.on( users_organizations::org_uuid.eq(collections::org_uuid).and( users_organizations::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(ciphers_collections::collection_uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( collections_groups::groups_uuid.eq(groups::uuid) ) )) .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_organizations::access_all.eq(true)) // User has access all .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .select(ciphers_collections::all_columns) .distinct() .load::<(CipherId, CollectionId)>(conn) .unwrap_or_default() }} } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct CipherId(String); ================================================ FILE: src/db/models/collection.rs ================================================ use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, User, UserId, }; use crate::db::schema::{ ciphers_collections, collections, collections_groups, groups, groups_users, users_collections, users_organizations, }; use crate::CONFIG; use diesel::prelude::*; use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = collections)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Collection { pub uuid: CollectionId, pub org_uuid: OrganizationId, pub name: String, pub external_id: Option, } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = users_collections)] #[diesel(primary_key(user_uuid, collection_uuid))] pub struct CollectionUser { pub user_uuid: UserId, pub collection_uuid: CollectionId, pub read_only: bool, pub hide_passwords: bool, pub manage: bool, } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = ciphers_collections)] #[diesel(primary_key(cipher_uuid, collection_uuid))] pub struct CollectionCipher { pub cipher_uuid: CipherId, pub collection_uuid: CollectionId, } /// Local methods impl Collection { pub fn new(org_uuid: OrganizationId, name: String, external_id: Option) -> Self { let mut new_model = Self { uuid: CollectionId(crate::util::get_uuid()), org_uuid, name, external_id: None, }; new_model.set_external_id(external_id); new_model } pub fn to_json(&self) -> Value { json!({ "externalId": self.external_id, "id": self.uuid, "organizationId": self.org_uuid, "name": self.name, "object": "collection", }) } pub fn set_external_id(&mut self, external_id: Option) { //Check if external id is empty. We don't want to have //empty strings in the database match external_id { Some(external_id) => { if external_id.is_empty() { self.external_id = None; } else { self.external_id = Some(external_id) } } None => self.external_id = None, } } pub async fn to_json_details( &self, user_uuid: &UserId, cipher_sync_data: Option<&crate::api::core::CipherSyncData>, conn: &DbConn, ) -> Value { let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { match cipher_sync_data.members.get(&self.org_uuid) { // Only for Manager types Bitwarden returns true for the manage option // Owners and Admins always have true. Users are not able to have full access Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) => { // Only let a manager manage collections when the have full read/write access let is_manager = m.atype == MembershipType::Manager; if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { ( cu.read_only, cu.hide_passwords, is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)), ) } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { ( cg.read_only, cg.hide_passwords, is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)), ) } else { (false, false, false) } } _ => (true, true, false), } } else { match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { (false, false, true) } Some(m) => { let is_manager = m.atype == MembershipType::Manager; let read_only = !self.is_writable_by_user(user_uuid, conn).await; let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await; (read_only, hide_passwords, is_manager && !read_only && !hide_passwords) } _ => (true, true, false), } }; let mut json_object = self.to_json(); json_object["object"] = json!("collectionDetails"); json_object["readOnly"] = json!(read_only); json_object["hidePasswords"] = json!(hide_passwords); json_object["manage"] = json!(manage); json_object } pub async fn can_access_collection(member: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { member.has_status(MembershipStatus::Confirmed) && (member.has_full_access() || CollectionUser::has_access_to_collection_by_user(col_id, &member.user_uuid, conn).await || (CONFIG.org_groups_enabled() && (GroupUser::has_full_access_by_member(&member.org_uuid, &member.uuid, conn).await || GroupUser::has_access_to_collection_by_member(col_id, &member.uuid, conn).await))) } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Collection { pub async fn save(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(collections::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(collections::table) .filter(collections::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving collection") } Err(e) => Err(e.into()), }.map_res("Error saving collection") } postgresql { diesel::insert_into(collections::table) .values(self) .on_conflict(collections::uuid) .do_update() .set(self) .execute(conn) .map_res("Error saving collection") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?; CollectionUser::delete_all_by_collection(&self.uuid, conn).await?; CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting collection") }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { for collection in Self::find_by_organization(org_uuid, conn).await { collection.delete(conn).await?; } Ok(()) } pub async fn update_users_revision(&self, conn: &DbConn) { for member in Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await.iter() { User::update_uuid_revision(&member.user_uuid, conn).await; } } pub async fn find_by_uuid(uuid: &CollectionId, conn: &DbConn) -> Option { db_run! { conn: { collections::table .filter(collections::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_user_uuid(user_uuid: UserId, conn: &DbConn) -> Vec { if CONFIG.org_groups_enabled() { db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid.clone()) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::collections_uuid.eq(collections::uuid) ) )) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .filter( users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection users_organizations::access_all.eq(true) // access_all in Organization ).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( collections_groups::collections_uuid.is_not_null() ) ) ) .select(collections::all_columns) .distinct() .load::(conn) .expect("Error loading collections") }} } else { db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid.clone()) ) )) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .filter( users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection users_organizations::access_all.eq(true) // access_all in Organization ) ) .select(collections::all_columns) .distinct() .load::(conn) .expect("Error loading collections") }} } } pub async fn find_by_organization_and_user_uuid( org_uuid: &OrganizationId, user_uuid: &UserId, conn: &DbConn, ) -> Vec { Self::find_by_user_uuid(user_uuid.to_owned(), conn) .await .into_iter() .filter(|c| &c.org_uuid == org_uuid) .collect() } pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { collections::table .filter(collections::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading collections") }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { collections::table .filter(collections::org_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_uuid_and_org(uuid: &CollectionId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { collections::table .filter(collections::uuid.eq(uuid)) .filter(collections::org_uuid.eq(org_uuid)) .select(collections::all_columns) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option { if CONFIG.org_groups_enabled() { db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::collections_uuid.eq(collections::uuid) ) )) .filter(collections::uuid.eq(uuid)) .filter( users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner )).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( collections_groups::collections_uuid.is_not_null() ) ) ).select(collections::all_columns) .first::(conn) .ok() }} } else { db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid) ) )) .filter(collections::uuid.eq(uuid)) .filter( users_collections::collection_uuid.eq(uuid).or( // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner )) ).select(collections::all_columns) .first::(conn) .ok() }} } } pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { let user_uuid = user_uuid.to_string(); if CONFIG.org_groups_enabled() { db_run! { conn: { collections::table .filter(collections::uuid.eq(&self.uuid)) .inner_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid) .and(users_collections::user_uuid.eq(user_uuid)) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) .and(collections_groups::collections_uuid.eq(collections::uuid)) )) .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection .and(users_collections::read_only.eq(false))) .or(groups::access_all.eq(true)) // access_all via group .or(collections_groups::collections_uuid.is_not_null() // write access given via group .and(collections_groups::read_only.eq(false))) ) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } else { db_run! { conn: { collections::table .filter(collections::uuid.eq(&self.uuid)) .inner_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid.clone())) )) .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid) .and(users_collections::user_uuid.eq(user_uuid)) )) .filter(users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid.eq(&self.uuid) // write access given to collection .and(users_collections::read_only.eq(false))) ) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } } pub async fn hide_passwords_for_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { let user_uuid = user_uuid.to_string(); db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::collections_uuid.eq(collections::uuid) ) )) .filter(collections::uuid.eq(&self.uuid)) .filter( users_collections::collection_uuid.eq(&self.uuid).and(users_collections::hide_passwords.eq(true)).or(// Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner )).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( collections_groups::collections_uuid.is_not_null().and( collections_groups::hide_passwords.eq(true)) ) ) ) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { let uuid = uuid.to_string(); let user_uuid = user_uuid.to_string(); db_run! { conn: { collections::table .left_join(users_collections::table.on( users_collections::collection_uuid.eq(collections::uuid).and( users_collections::user_uuid.eq(user_uuid.clone()) ) )) .left_join(users_organizations::table.on( collections::org_uuid.eq(users_organizations::org_uuid).and( users_organizations::user_uuid.eq(user_uuid) ) )) .left_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::collections_uuid.eq(collections::uuid) ) )) .filter(collections::uuid.eq(&uuid)) .filter( users_collections::collection_uuid.eq(&uuid).and(users_collections::manage.eq(true)).or(// Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization users_organizations::atype.le(MembershipType::Admin as i32) // Org admin or owner )).or( groups::access_all.eq(true) // access_all in groups ).or( // access via groups groups_users::users_organizations_uuid.eq(users_organizations::uuid).and( collections_groups::collections_uuid.is_not_null().and( collections_groups::manage.eq(true)) ) ) ) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await } } /// Database methods impl CollectionUser { pub async fn find_by_organization_and_user_uuid( org_uuid: &OrganizationId, user_uuid: &UserId, conn: &DbConn, ) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::user_uuid.eq(user_uuid)) .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) .filter(collections::org_uuid.eq(org_uuid)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") }} } pub async fn find_by_organization_swap_user_uuid_with_member_uuid( org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { let col_users = db_run! { conn: { users_collections::table .inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid))) .filter(collections::org_uuid.eq(org_uuid)) .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) .filter(users_organizations::org_uuid.eq(org_uuid)) .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::(conn) .expect("Error loading users_collections") }}; col_users.into_iter().map(|c| c.into()).collect() } pub async fn save( user_uuid: &UserId, collection_uuid: &CollectionId, read_only: bool, hide_passwords: bool, manage: bool, conn: &DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(users_collections::table) .values(( users_collections::user_uuid.eq(user_uuid), users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), users_collections::manage.eq(manage), )) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(users_collections::table) .filter(users_collections::user_uuid.eq(user_uuid)) .filter(users_collections::collection_uuid.eq(collection_uuid)) .set(( 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), users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") } Err(e) => Err(e.into()), }.map_res("Error adding user to collection") } postgresql { diesel::insert_into(users_collections::table) .values(( users_collections::user_uuid.eq(user_uuid), users_collections::collection_uuid.eq(collection_uuid), users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), users_collections::manage.eq(manage), )) .on_conflict((users_collections::user_uuid, users_collections::collection_uuid)) .do_update() .set(( users_collections::read_only.eq(read_only), users_collections::hide_passwords.eq(hide_passwords), users_collections::manage.eq(manage), )) .execute(conn) .map_res("Error adding user to collection") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: { diesel::delete( users_collections::table .filter(users_collections::user_uuid.eq(&self.user_uuid)) .filter(users_collections::collection_uuid.eq(&self.collection_uuid)), ) .execute(conn) .map_res("Error removing user from collection") }} } pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") }} } pub async fn find_by_org_and_coll_swap_user_uuid_with_member_uuid( org_uuid: &OrganizationId, collection_uuid: &CollectionId, conn: &DbConn, ) -> Vec { let col_users = db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .inner_join(users_organizations::table.on(users_organizations::user_uuid.eq(users_collections::user_uuid))) .select((users_organizations::uuid, users_collections::collection_uuid, users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .load::(conn) .expect("Error loading users_collections") }}; col_users.into_iter().map(|c| c.into()).collect() } pub async fn find_by_collection_and_user( collection_uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn, ) -> Option { db_run! { conn: { users_collections::table .filter(users_collections::collection_uuid.eq(collection_uuid)) .filter(users_collections::user_uuid.eq(user_uuid)) .select(users_collections::all_columns) .first::(conn) .ok() }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_collections::table .filter(users_collections::user_uuid.eq(user_uuid)) .select(users_collections::all_columns) .load::(conn) .expect("Error loading users_collections") }} } pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { for collection in CollectionUser::find_by_collection(collection_uuid, conn).await.iter() { User::update_uuid_revision(&collection.user_uuid, conn).await; } db_run! { conn: { diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid))) .execute(conn) .map_res("Error deleting users from collection") }} } pub async fn delete_all_by_user_and_org( user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn, ) -> EmptyResult { let collectionusers = Self::find_by_organization_and_user_uuid(org_uuid, user_uuid, conn).await; db_run! { conn: { for user in collectionusers { let _: () = diesel::delete(users_collections::table.filter( users_collections::user_uuid.eq(user_uuid) .and(users_collections::collection_uuid.eq(user.collection_uuid)) )) .execute(conn) .map_res("Error removing user from collections")?; } Ok(()) }} } pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some() } } /// Database methods impl CollectionCipher { pub async fn save(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { Self::update_users_revision(collection_uuid, conn).await; db_run! { conn: sqlite, mysql { // Not checking for ForeignKey Constraints here. // Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts. // This table has no constraints pointing to itself, but only to others. diesel::replace_into(ciphers_collections::table) .values(( ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::collection_uuid.eq(collection_uuid), )) .execute(conn) .map_res("Error adding cipher to collection") } postgresql { diesel::insert_into(ciphers_collections::table) .values(( ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::collection_uuid.eq(collection_uuid), )) .on_conflict((ciphers_collections::cipher_uuid, ciphers_collections::collection_uuid)) .do_nothing() .execute(conn) .map_res("Error adding cipher to collection") } } } pub async fn delete(cipher_uuid: &CipherId, collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { Self::update_users_revision(collection_uuid, conn).await; db_run! { conn: { diesel::delete( ciphers_collections::table .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) .filter(ciphers_collections::collection_uuid.eq(collection_uuid)), ) .execute(conn) .map_res("Error deleting cipher from collection") }} } pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing cipher from collections") }} } pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(ciphers_collections::table.filter(ciphers_collections::collection_uuid.eq(collection_uuid))) .execute(conn) .map_res("Error removing ciphers from collection") }} } pub async fn update_users_revision(collection_uuid: &CollectionId, conn: &DbConn) { if let Some(collection) = Collection::find_by_uuid(collection_uuid, conn).await { collection.update_users_revision(conn).await; } } } // Added in case we need the membership_uuid instead of the user_uuid pub struct CollectionMembership { pub membership_uuid: MembershipId, pub collection_uuid: CollectionId, pub read_only: bool, pub hide_passwords: bool, pub manage: bool, } impl CollectionMembership { pub fn to_json_details_for_member(&self, membership_type: i32) -> Value { json!({ "id": self.membership_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, "manage": membership_type >= MembershipType::Admin || self.manage || (membership_type == MembershipType::Manager && !self.read_only && !self.hide_passwords), }) } } impl From for CollectionMembership { fn from(c: CollectionUser) -> Self { Self { membership_uuid: c.user_uuid.to_string().into(), collection_uuid: c.collection_uuid, read_only: c.read_only, hide_passwords: c.hide_passwords, manage: c.manage, } } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct CollectionId(String); ================================================ FILE: src/db/models/device.rs ================================================ use chrono::{NaiveDateTime, Utc}; use data_encoding::{BASE64, BASE64URL}; use derive_more::{Display, From}; use serde_json::Value; use super::{AuthRequest, UserId}; use crate::db::schema::devices; use crate::{ crypto, util::{format_date, get_uuid}, }; use diesel::prelude::*; use macros::{IdFromParam, UuidFromParam}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = devices)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid, user_uuid))] pub struct Device { pub uuid: DeviceId, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub user_uuid: UserId, pub name: String, pub atype: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub push_uuid: Option, pub push_token: Option, pub refresh_token: String, pub twofactor_remember: Option, } /// Local methods impl Device { pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self { let now = Utc::now().naive_utc(); Self { uuid, created_at: now, updated_at: now, user_uuid, name, atype, push_uuid: Some(PushId(get_uuid())), push_token: None, refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL), twofactor_remember: None, } } pub fn to_json(&self) -> Value { json!({ "id": self.uuid, "name": self.name, "type": self.atype, "identifier": self.uuid, "creationDate": format_date(&self.created_at), "isTrusted": false, "object":"device" }) } pub fn refresh_twofactor_remember(&mut self) -> String { let twofactor_remember = crypto::encode_random_bytes::<180>(&BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); twofactor_remember } pub fn delete_twofactor_remember(&mut self) { self.twofactor_remember = None; } // This rely on the fact we only update the device after a successful login pub fn is_new(&self) -> bool { self.created_at == self.updated_at } pub fn is_push_device(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) } pub fn is_cli(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::WindowsCLI | DeviceType::MacOsCLI | DeviceType::LinuxCLI) } pub fn is_mobile(&self) -> bool { matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) } } pub struct DeviceWithAuthRequest { pub device: Device, pub pending_auth_request: Option, } impl DeviceWithAuthRequest { pub fn to_json(&self) -> Value { let auth_request = match &self.pending_auth_request { Some(auth_request) => auth_request.to_json_for_pending_device(), None => Value::Null, }; json!({ "id": self.device.uuid, "name": self.device.name, "type": self.device.atype, "identifier": self.device.uuid, "creationDate": format_date(&self.device.created_at), "devicePendingAuthRequest": auth_request, "isTrusted": false, "encryptedPublicKey": null, "encryptedUserKey": null, "object": "device", }) } pub fn from(c: Device, a: Option) -> Self { Self { device: c, pending_auth_request: a, } } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Device { pub async fn save(&mut self, update_time: bool, conn: &DbConn) -> EmptyResult { if update_time { self.updated_at = Utc::now().naive_utc(); } db_run! { conn: sqlite, mysql { crate::util::retry(|| diesel::replace_into(devices::table) .values(&*self) .execute(conn), 10, ).map_res("Error saving device") } postgresql { crate::util::retry(|| diesel::insert_into(devices::table) .values(&*self) .on_conflict((devices::uuid, devices::user_uuid)) .do_update() .set(&*self) .execute(conn), 10, ).map_res("Error saving device") } } } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error removing devices for user") }} } pub async fn find_by_uuid_and_user(uuid: &DeviceId, user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::uuid.eq(uuid)) .filter(devices::user_uuid.eq(user_uuid)) .first::(conn) .ok() }} } pub async fn find_with_auth_request_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { let devices = Self::find_by_user(user_uuid, conn).await; let mut result = Vec::new(); for device in devices { let auth_request = AuthRequest::find_by_user_and_requested_device(user_uuid, &device.uuid, conn).await; result.push(DeviceWithAuthRequest::from(device, auth_request)); } result } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading devices") }} } pub async fn find_by_uuid(uuid: &DeviceId, conn: &DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn clear_push_token_by_uuid(uuid: &DeviceId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::update(devices::table) .filter(devices::uuid.eq(uuid)) .set(devices::push_token.eq::>(None)) .execute(conn) .map_res("Error removing push token") }} } pub async fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::refresh_token.eq(refresh_token)) .first::(conn) .ok() }} } pub async fn find_latest_active_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .order(devices::updated_at.desc()) .first::(conn) .ok() }} } pub async fn find_push_devices_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .filter(devices::push_token.is_not_null()) .load::(conn) .expect("Error loading push devices") }} } pub async fn check_user_has_push_device(user_uuid: &UserId, conn: &DbConn) -> bool { db_run! { conn: { devices::table .filter(devices::user_uuid.eq(user_uuid)) .filter(devices::push_token.is_not_null()) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } } #[derive(Display)] pub enum DeviceType { #[display("Android")] Android = 0, #[display("iOS")] Ios = 1, #[display("Chrome Extension")] ChromeExtension = 2, #[display("Firefox Extension")] FirefoxExtension = 3, #[display("Opera Extension")] OperaExtension = 4, #[display("Edge Extension")] EdgeExtension = 5, #[display("Windows")] WindowsDesktop = 6, #[display("macOS")] MacOsDesktop = 7, #[display("Linux")] LinuxDesktop = 8, #[display("Chrome")] ChromeBrowser = 9, #[display("Firefox")] FirefoxBrowser = 10, #[display("Opera")] OperaBrowser = 11, #[display("Edge")] EdgeBrowser = 12, #[display("Internet Explorer")] IEBrowser = 13, #[display("Unknown Browser")] UnknownBrowser = 14, #[display("Android")] AndroidAmazon = 15, #[display("UWP")] Uwp = 16, #[display("Safari")] SafariBrowser = 17, #[display("Vivaldi")] VivaldiBrowser = 18, #[display("Vivaldi Extension")] VivaldiExtension = 19, #[display("Safari Extension")] SafariExtension = 20, #[display("SDK")] Sdk = 21, #[display("Server")] Server = 22, #[display("Windows CLI")] WindowsCLI = 23, #[display("macOS CLI")] MacOsCLI = 24, #[display("Linux CLI")] LinuxCLI = 25, } impl DeviceType { pub fn from_i32(value: i32) -> DeviceType { match value { 0 => DeviceType::Android, 1 => DeviceType::Ios, 2 => DeviceType::ChromeExtension, 3 => DeviceType::FirefoxExtension, 4 => DeviceType::OperaExtension, 5 => DeviceType::EdgeExtension, 6 => DeviceType::WindowsDesktop, 7 => DeviceType::MacOsDesktop, 8 => DeviceType::LinuxDesktop, 9 => DeviceType::ChromeBrowser, 10 => DeviceType::FirefoxBrowser, 11 => DeviceType::OperaBrowser, 12 => DeviceType::EdgeBrowser, 13 => DeviceType::IEBrowser, 14 => DeviceType::UnknownBrowser, 15 => DeviceType::AndroidAmazon, 16 => DeviceType::Uwp, 17 => DeviceType::SafariBrowser, 18 => DeviceType::VivaldiBrowser, 19 => DeviceType::VivaldiExtension, 20 => DeviceType::SafariExtension, 21 => DeviceType::Sdk, 22 => DeviceType::Server, 23 => DeviceType::WindowsCLI, 24 => DeviceType::MacOsCLI, 25 => DeviceType::LinuxCLI, _ => DeviceType::UnknownBrowser, } } } #[derive( Clone, Debug, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, )] pub struct DeviceId(String); #[derive(Clone, Debug, DieselNewType, Display, From, FromForm, Serialize, Deserialize, UuidFromParam)] pub struct PushId(pub String); ================================================ FILE: src/db/models/emergency_access.rs ================================================ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; use super::{User, UserId}; use crate::db::schema::emergency_access; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use diesel::prelude::*; use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = emergency_access)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct EmergencyAccess { pub uuid: EmergencyAccessId, pub grantor_uuid: UserId, pub grantee_uuid: Option, pub email: Option, pub key_encrypted: Option, pub atype: i32, //EmergencyAccessType pub status: i32, //EmergencyAccessStatus pub wait_time_days: i32, pub recovery_initiated_at: Option, pub last_notification_at: Option, pub updated_at: NaiveDateTime, pub created_at: NaiveDateTime, } // Local methods impl EmergencyAccess { pub fn new(grantor_uuid: UserId, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self { let now = Utc::now().naive_utc(); Self { uuid: EmergencyAccessId(crate::util::get_uuid()), grantor_uuid, grantee_uuid: None, email: Some(email), status, atype, wait_time_days, recovery_initiated_at: None, created_at: now, updated_at: now, key_encrypted: None, last_notification_at: None, } } pub fn get_type_as_str(&self) -> &'static str { if self.atype == EmergencyAccessType::View as i32 { "View" } else { "Takeover" } } pub fn to_json(&self) -> Value { json!({ "id": self.uuid, "status": self.status, "type": self.atype, "waitTimeDays": self.wait_time_days, "object": "emergencyAccess", }) } pub async fn to_json_grantor_details(&self, conn: &DbConn) -> Value { let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).await.expect("Grantor user not found."); json!({ "id": self.uuid, "status": self.status, "type": self.atype, "waitTimeDays": self.wait_time_days, "grantorId": grantor_user.uuid, "email": grantor_user.email, "name": grantor_user.name, "avatarColor": grantor_user.avatar_color, "object": "emergencyAccessGrantorDetails", }) } pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option { let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid { User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.") } else if let Some(email) = self.email.as_deref() { match User::find_by_mail(email, conn).await { Some(user) => user, None => { // remove outstanding invitations which should not exist Self::delete_all_by_grantee_email(email, conn).await.ok(); return None; } } } else { return None; }; Some(json!({ "id": self.uuid, "status": self.status, "type": self.atype, "waitTimeDays": self.wait_time_days, "granteeId": grantee_user.uuid, "email": grantee_user.email, "name": grantee_user.name, "avatarColor": grantee_user.avatar_color, "object": "emergencyAccessGranteeDetails", })) } } #[derive(Copy, Clone)] pub enum EmergencyAccessType { View = 0, Takeover = 1, } impl EmergencyAccessType { pub fn from_str(s: &str) -> Option { match s { "0" | "View" => Some(EmergencyAccessType::View), "1" | "Takeover" => Some(EmergencyAccessType::Takeover), _ => None, } } } pub enum EmergencyAccessStatus { Invited = 0, Accepted = 1, Confirmed = 2, RecoveryInitiated = 3, RecoveryApproved = 4, } // region Database methods impl EmergencyAccess { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.grantor_uuid, conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(emergency_access::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(emergency_access::table) .filter(emergency_access::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error updating emergency access") } Err(e) => Err(e.into()), }.map_res("Error saving emergency access") } postgresql { diesel::insert_into(emergency_access::table) .values(&*self) .on_conflict(emergency_access::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving emergency access") } } } pub async fn update_access_status_and_save( &mut self, status: i32, date: &NaiveDateTime, conn: &DbConn, ) -> EmptyResult { // Update the grantee so that it will refresh it's status. User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; self.status = status; date.clone_into(&mut self.updated_at); db_run! { conn: { crate::util::retry(|| { diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) .set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date))) .execute(conn) }, 10) .map_res("Error updating emergency access status") }} } pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { self.last_notification_at = Some(date.to_owned()); date.clone_into(&mut self.updated_at); db_run! { conn: { crate::util::retry(|| { diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) .set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date))) .execute(conn) }, 10) .map_res("Error updating emergency access status") }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for ea in Self::find_all_by_grantor_uuid(user_uuid, conn).await { ea.delete(conn).await?; } for ea in Self::find_all_by_grantee_uuid(user_uuid, conn).await { ea.delete(conn).await?; } Ok(()) } pub async fn delete_all_by_grantee_email(grantee_email: &str, conn: &DbConn) -> EmptyResult { for ea in Self::find_all_invited_by_grantee_email(grantee_email, conn).await { ea.delete(conn).await?; } Ok(()) } pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.grantor_uuid, conn).await; db_run! { conn: { diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid))) .execute(conn) .map_res("Error removing user from emergency access") }} } pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email( grantor_uuid: &UserId, grantee_uuid: &UserId, email: &str, conn: &DbConn, ) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email))) .first::(conn) .ok() }} } pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32)) .filter(emergency_access::recovery_initiated_at.is_not_null()) .load::(conn) .expect("Error loading emergency_access") }} } pub async fn find_by_uuid_and_grantor_uuid( uuid: &EmergencyAccessId, grantor_uuid: &UserId, conn: &DbConn, ) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_grantee_uuid( uuid: &EmergencyAccessId, grantee_uuid: &UserId, conn: &DbConn, ) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_grantee_email( uuid: &EmergencyAccessId, grantee_email: &str, conn: &DbConn, ) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::uuid.eq(uuid)) .filter(emergency_access::email.eq(grantee_email)) .first::(conn) .ok() }} } pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) .load::(conn) .expect("Error loading emergency_access") }} } pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option { db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) .first::(conn) .ok() }} } pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::email.eq(grantee_email)) .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) .load::(conn) .expect("Error loading emergency_access") }} } pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .load::(conn) .expect("Error loading emergency_access") }} } pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { emergency_access::table .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) .filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32)) .load::(conn) .expect("Error loading emergency_access") }} } pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult { if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email { err!("User email does not match invite."); } if self.status == EmergencyAccessStatus::Accepted as i32 { err!("Emergency contact already accepted."); } self.status = EmergencyAccessStatus::Accepted as i32; self.grantee_uuid = Some(grantee_uuid.clone()); self.email = None; self.save(conn).await } } // endregion #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct EmergencyAccessId(String); ================================================ FILE: src/db/models/event.rs ================================================ use chrono::{NaiveDateTime, TimeDelta, Utc}; //use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; use super::{CipherId, CollectionId, GroupId, MembershipId, OrgPolicyId, OrganizationId, UserId}; use crate::db::schema::{event, users_organizations}; use crate::{api::EmptyResult, db::DbConn, error::MapResult, CONFIG}; use diesel::prelude::*; // https://bitwarden.com/help/event-logs/ // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs // Upstream SQL: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Sql/dbo/Tables/Event.sql #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = event)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Event { pub uuid: EventId, pub event_type: i32, // EventType pub user_uuid: Option, pub org_uuid: Option, pub cipher_uuid: Option, pub collection_uuid: Option, pub group_uuid: Option, pub org_user_uuid: Option, pub act_user_uuid: Option, // Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub device_type: Option, pub ip_address: Option, pub event_date: NaiveDateTime, pub policy_uuid: Option, pub provider_uuid: Option, pub provider_user_uuid: Option, pub provider_org_uuid: Option, } // Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs #[derive(Debug, Copy, Clone)] pub enum EventType { // User UserLoggedIn = 1000, UserChangedPassword = 1001, UserUpdated2fa = 1002, UserDisabled2fa = 1003, UserRecovered2fa = 1004, UserFailedLogIn = 1005, UserFailedLogIn2fa = 1006, UserClientExportedVault = 1007, // UserUpdatedTempPassword = 1008, // Not supported // UserMigratedKeyToKeyConnector = 1009, // Not supported UserRequestedDeviceApproval = 1010, // UserTdeOffboardingPasswordSet = 1011, // Not supported // Cipher CipherCreated = 1100, CipherUpdated = 1101, CipherDeleted = 1102, CipherAttachmentCreated = 1103, CipherAttachmentDeleted = 1104, CipherShared = 1105, CipherUpdatedCollections = 1106, CipherClientViewed = 1107, CipherClientToggledPasswordVisible = 1108, CipherClientToggledHiddenFieldVisible = 1109, CipherClientToggledCardCodeVisible = 1110, CipherClientCopiedPassword = 1111, CipherClientCopiedHiddenField = 1112, CipherClientCopiedCardCode = 1113, CipherClientAutofilled = 1114, CipherSoftDeleted = 1115, CipherRestored = 1116, CipherClientToggledCardNumberVisible = 1117, // Collection CollectionCreated = 1300, CollectionUpdated = 1301, CollectionDeleted = 1302, // Group GroupCreated = 1400, GroupUpdated = 1401, GroupDeleted = 1402, // OrganizationUser OrganizationUserInvited = 1500, OrganizationUserConfirmed = 1501, OrganizationUserUpdated = 1502, OrganizationUserRemoved = 1503, // Organization user data was deleted OrganizationUserUpdatedGroups = 1504, OrganizationUserUnlinkedSso = 1505, OrganizationUserResetPasswordEnroll = 1506, OrganizationUserResetPasswordWithdraw = 1507, OrganizationUserAdminResetPassword = 1508, // OrganizationUserResetSsoLink = 1509, // Not supported // OrganizationUserFirstSsoLogin = 1510, // Not supported OrganizationUserRevoked = 1511, OrganizationUserRestored = 1512, OrganizationUserApprovedAuthRequest = 1513, OrganizationUserRejectedAuthRequest = 1514, OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserLeft = 1516, // User voluntarily left the organization // Organization OrganizationUpdated = 1600, OrganizationPurgedVault = 1601, OrganizationClientExportedVault = 1602, // OrganizationVaultAccessed = 1603, // OrganizationEnabledSso = 1604, // Not supported // OrganizationDisabledSso = 1605, // Not supported // OrganizationEnabledKeyConnector = 1606, // Not supported // OrganizationDisabledKeyConnector = 1607, // Not supported // OrganizationSponsorshipsSynced = 1608, // Not supported // OrganizationCollectionManagementUpdated = 1609, // Not supported // Policy PolicyUpdated = 1700, // Provider (Not yet supported) // ProviderUserInvited = 1800, // Not supported // ProviderUserConfirmed = 1801, // Not supported // ProviderUserUpdated = 1802, // Not supported // ProviderUserRemoved = 1803, // Not supported // ProviderOrganizationCreated = 1900, // Not supported // ProviderOrganizationAdded = 1901, // Not supported // ProviderOrganizationRemoved = 1902, // Not supported // ProviderOrganizationVaultAccessed = 1903, // Not supported // OrganizationDomainAdded = 2000, // Not supported // OrganizationDomainRemoved = 2001, // Not supported // OrganizationDomainVerified = 2002, // Not supported // OrganizationDomainNotVerified = 2003, // Not supported // SecretRetrieved = 2100, // Not supported } /// Local methods impl Event { pub fn new(event_type: i32, event_date: Option) -> Self { let event_date = match event_date { Some(d) => d, None => Utc::now().naive_utc(), }; Self { uuid: EventId(crate::util::get_uuid()), event_type, user_uuid: None, org_uuid: None, cipher_uuid: None, collection_uuid: None, group_uuid: None, org_user_uuid: None, act_user_uuid: None, device_type: None, ip_address: None, event_date, policy_uuid: None, provider_uuid: None, provider_user_uuid: None, provider_org_uuid: None, } } pub fn to_json(&self) -> Value { use crate::util::format_date; json!({ "type": self.event_type, "userId": self.user_uuid, "organizationId": self.org_uuid, "cipherId": self.cipher_uuid, "collectionId": self.collection_uuid, "groupId": self.group_uuid, "organizationUserId": self.org_user_uuid, "actingUserId": self.act_user_uuid, "date": format_date(&self.event_date), "deviceType": self.device_type, "ipAddress": self.ip_address, "policyId": self.policy_uuid, "providerId": self.provider_uuid, "providerUserId": self.provider_user_uuid, "providerOrganizationId": self.provider_org_uuid, // "installationId": null, // Not supported }) } } /// Database methods /// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs impl Event { pub const PAGE_SIZE: i64 = 30; /// ############# /// Basic Queries pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { diesel::replace_into(event::table) .values(self) .execute(conn) .map_res("Error saving event") } postgresql { diesel::insert_into(event::table) .values(self) .on_conflict(event::uuid) .do_update() .set(self) .execute(conn) .map_res("Error saving event") } } } pub async fn save_user_event(events: Vec, conn: &DbConn) -> EmptyResult { // Special save function which is able to handle multiple events. // SQLite doesn't support the DEFAULT argument, and does not support inserting multiple values at the same time. // MySQL and PostgreSQL do. // We also ignore duplicate if they ever will exists, else it could break the whole flow. db_run! { conn: // Unfortunately SQLite does not support inserting multiple records at the same time // We loop through the events here and insert them one at a time. sqlite { for event in events { diesel::insert_or_ignore_into(event::table) .values(&event) .execute(conn) .unwrap_or_default(); } Ok(()) } mysql { diesel::insert_or_ignore_into(event::table) .values(&events) .execute(conn) .unwrap_or_default(); Ok(()) } postgresql { diesel::insert_into(event::table) .values(&events) .on_conflict_do_nothing() .execute(conn) .unwrap_or_default(); Ok(()) } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(event::table.filter(event::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting event") }} } /// ############## /// Custom Queries pub async fn find_by_organization_uuid( org_uuid: &OrganizationId, start: &NaiveDateTime, end: &NaiveDateTime, conn: &DbConn, ) -> Vec { db_run! { conn: { event::table .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { event::table .filter(event::org_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_org_and_member( org_uuid: &OrganizationId, member_uuid: &MembershipId, start: &NaiveDateTime, end: &NaiveDateTime, conn: &DbConn, ) -> Vec { db_run! { conn: { event::table .inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid))) .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) .filter(event::user_uuid.eq(users_organizations::user_uuid.nullable()).or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable()))) .select(event::all_columns) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") }} } pub async fn find_by_cipher_uuid( cipher_uuid: &CipherId, start: &NaiveDateTime, end: &NaiveDateTime, conn: &DbConn, ) -> Vec { db_run! { conn: { event::table .filter(event::cipher_uuid.eq(cipher_uuid)) .filter(event::event_date.between(start, end)) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) .load::(conn) .expect("Error filtering events") }} } pub async fn clean_events(conn: &DbConn) -> EmptyResult { if let Some(days_to_retain) = CONFIG.events_days_retain() { let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); db_run! { conn: { diesel::delete(event::table.filter(event::event_date.lt(dt))) .execute(conn) .map_res("Error cleaning old events") }} } else { Ok(()) } } } #[derive(Clone, Debug, DieselNewType, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct EventId(String); ================================================ FILE: src/db/models/favorite.rs ================================================ use super::{CipherId, User, UserId}; use crate::db::schema::favorites; use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = favorites)] #[diesel(primary_key(user_uuid, cipher_uuid))] pub struct Favorite { pub user_uuid: UserId, pub cipher_uuid: CipherId, } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; impl Favorite { // Returns whether the specified cipher is a favorite of the specified user. pub async fn is_favorite(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> bool { db_run! { conn: { let query = favorites::table .filter(favorites::cipher_uuid.eq(cipher_uuid)) .filter(favorites::user_uuid.eq(user_uuid)) .count(); query.first::(conn) .ok() .unwrap_or(0) != 0 }} } // Sets whether the specified cipher is a favorite of the specified user. pub async fn set_favorite( favorite: bool, cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn, ) -> EmptyResult { let (old, new) = (Self::is_favorite(cipher_uuid, user_uuid, conn).await, favorite); match (old, new) { (false, true) => { User::update_uuid_revision(user_uuid, conn).await; db_run! { conn: { diesel::insert_into(favorites::table) .values(( favorites::user_uuid.eq(user_uuid), favorites::cipher_uuid.eq(cipher_uuid), )) .execute(conn) .map_res("Error adding favorite") }} } (true, false) => { User::update_uuid_revision(user_uuid, conn).await; db_run! { conn: { diesel::delete( favorites::table .filter(favorites::user_uuid.eq(user_uuid)) .filter(favorites::cipher_uuid.eq(cipher_uuid)) ) .execute(conn) .map_res("Error removing favorite") }} } // Otherwise, the favorite status is already what it should be. _ => Ok(()), } } // Delete all favorite entries associated with the specified cipher. pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(favorites::table.filter(favorites::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing favorites by cipher") }} } // Delete all favorite entries associated with the specified user. pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(favorites::table.filter(favorites::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error removing favorites by user") }} } /// Return a vec with (cipher_uuid) this will only contain favorite flagged ciphers /// This is used during a full sync so we only need one query for all favorite cipher matches. pub async fn get_all_cipher_uuid_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { favorites::table .filter(favorites::user_uuid.eq(user_uuid)) .select(favorites::cipher_uuid) .load::(conn) .unwrap_or_default() }} } } ================================================ FILE: src/db/models/folder.rs ================================================ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use serde_json::Value; use super::{CipherId, User, UserId}; use crate::db::schema::{folders, folders_ciphers}; use diesel::prelude::*; use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = folders)] #[diesel(primary_key(uuid))] pub struct Folder { pub uuid: FolderId, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub user_uuid: UserId, pub name: String, } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = folders_ciphers)] #[diesel(primary_key(cipher_uuid, folder_uuid))] pub struct FolderCipher { pub cipher_uuid: CipherId, pub folder_uuid: FolderId, } /// Local methods impl Folder { pub fn new(user_uuid: UserId, name: String) -> Self { let now = Utc::now().naive_utc(); Self { uuid: FolderId(crate::util::get_uuid()), created_at: now, updated_at: now, user_uuid, name, } } pub fn to_json(&self) -> Value { use crate::util::format_date; json!({ "id": self.uuid, "revisionDate": format_date(&self.updated_at), "name": self.name, "object": "folder", }) } } impl FolderCipher { pub fn new(folder_uuid: FolderId, cipher_uuid: CipherId) -> Self { Self { folder_uuid, cipher_uuid, } } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Folder { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; self.updated_at = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(folders::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(folders::table) .filter(folders::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error saving folder") } Err(e) => Err(e.into()), }.map_res("Error saving folder") } postgresql { diesel::insert_into(folders::table) .values(&*self) .on_conflict(folders::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving folder") } } } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; FolderCipher::delete_all_by_folder(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(folders::table.filter(folders::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting folder") }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for folder in Self::find_by_user(user_uuid, conn).await { folder.delete(conn).await?; } Ok(()) } pub async fn find_by_uuid_and_user(uuid: &FolderId, user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { folders::table .filter(folders::uuid.eq(uuid)) .filter(folders::user_uuid.eq(user_uuid)) .first::(conn) .ok() }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { folders::table .filter(folders::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading folders") }} } } impl FolderCipher { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { // Not checking for ForeignKey Constraints here. // Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts. // This table has no constraints pointing to itself, but only to others. diesel::replace_into(folders_ciphers::table) .values(self) .execute(conn) .map_res("Error adding cipher to folder") } postgresql { diesel::insert_into(folders_ciphers::table) .values(self) .on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid)) .do_nothing() .execute(conn) .map_res("Error adding cipher to folder") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete( folders_ciphers::table .filter(folders_ciphers::cipher_uuid.eq(self.cipher_uuid)) .filter(folders_ciphers::folder_uuid.eq(self.folder_uuid)), ) .execute(conn) .map_res("Error removing cipher from folder") }} } pub async fn delete_all_by_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::cipher_uuid.eq(cipher_uuid))) .execute(conn) .map_res("Error removing cipher from folders") }} } pub async fn delete_all_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(folders_ciphers::table.filter(folders_ciphers::folder_uuid.eq(folder_uuid))) .execute(conn) .map_res("Error removing ciphers from folder") }} } pub async fn find_by_folder_and_cipher( folder_uuid: &FolderId, cipher_uuid: &CipherId, conn: &DbConn, ) -> Option { db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .filter(folders_ciphers::cipher_uuid.eq(cipher_uuid)) .first::(conn) .ok() }} } pub async fn find_by_folder(folder_uuid: &FolderId, conn: &DbConn) -> Vec { db_run! { conn: { folders_ciphers::table .filter(folders_ciphers::folder_uuid.eq(folder_uuid)) .load::(conn) .expect("Error loading folders") }} } /// Return a vec with (cipher_uuid, folder_uuid) /// This is used during a full sync so we only need one query for all folder matches. pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, FolderId)> { db_run! { conn: { folders_ciphers::table .inner_join(folders::table) .filter(folders::user_uuid.eq(user_uuid)) .select(folders_ciphers::all_columns) .load::<(CipherId, FolderId)>(conn) .unwrap_or_default() }} } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct FolderId(String); ================================================ FILE: src/db/models/group.rs ================================================ use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; use crate::api::EmptyResult; use crate::db::schema::{collections_groups, groups, groups_users, users_organizations}; use crate::db::DbConn; use crate::error::MapResult; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use macros::UuidFromParam; use serde_json::Value; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = groups)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Group { pub uuid: GroupId, pub organizations_uuid: OrganizationId, pub name: String, pub access_all: bool, pub external_id: Option, pub creation_date: NaiveDateTime, pub revision_date: NaiveDateTime, } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = collections_groups)] #[diesel(primary_key(collections_uuid, groups_uuid))] pub struct CollectionGroup { pub collections_uuid: CollectionId, pub groups_uuid: GroupId, pub read_only: bool, pub hide_passwords: bool, pub manage: bool, } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = groups_users)] #[diesel(primary_key(groups_uuid, users_organizations_uuid))] pub struct GroupUser { pub groups_uuid: GroupId, pub users_organizations_uuid: MembershipId, } /// Local methods impl Group { pub fn new( organizations_uuid: OrganizationId, name: String, access_all: bool, external_id: Option, ) -> Self { let now = Utc::now().naive_utc(); let mut new_model = Self { uuid: GroupId(crate::util::get_uuid()), organizations_uuid, name, access_all, external_id: None, creation_date: now, revision_date: now, }; new_model.set_external_id(external_id); new_model } pub fn to_json(&self) -> Value { json!({ "id": self.uuid, "organizationId": self.organizations_uuid, "name": self.name, "externalId": self.external_id, "object": "group" }) } pub async fn to_json_details(&self, conn: &DbConn) -> Value { // If both read_only and hide_passwords are false, then manage should be true // You can't have an entry with read_only and manage, or hide_passwords and manage // Or an entry with everything to false let collections_groups: Vec = CollectionGroup::find_by_group(&self.uuid, conn) .await .iter() .map(|entry| { json!({ "id": entry.collections_uuid, "readOnly": entry.read_only, "hidePasswords": entry.hide_passwords, "manage": entry.manage, }) }) .collect(); json!({ "id": self.uuid, "organizationId": self.organizations_uuid, "name": self.name, "accessAll": self.access_all, "externalId": self.external_id, "collections": collections_groups, "object": "groupDetails" }) } pub fn set_external_id(&mut self, external_id: Option) { // Check if external_id is empty. We do not want to have empty strings in the database self.external_id = match external_id { Some(external_id) if !external_id.trim().is_empty() => Some(external_id), _ => None, }; } } impl CollectionGroup { pub fn new( collections_uuid: CollectionId, groups_uuid: GroupId, read_only: bool, hide_passwords: bool, manage: bool, ) -> Self { Self { collections_uuid, groups_uuid, read_only, hide_passwords, manage, } } pub fn to_json_details_for_group(&self) -> Value { // If both read_only and hide_passwords are false, then manage should be true // You can't have an entry with read_only and manage, or hide_passwords and manage // Or an entry with everything to false // For backwards compatibility and migration proposes we keep checking read_only and hide_password json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, "manage": self.manage || (!self.read_only && !self.hide_passwords), }) } } impl GroupUser { pub fn new(groups_uuid: GroupId, users_organizations_uuid: MembershipId) -> Self { Self { groups_uuid, users_organizations_uuid, } } } /// Database methods impl Group { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.revision_date = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(groups::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(groups::table) .filter(groups::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error saving group") } Err(e) => Err(e.into()), }.map_res("Error saving group") } postgresql { diesel::insert_into(groups::table) .values(&*self) .on_conflict(groups::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving group") } } } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { for group in Self::find_by_organization(org_uuid, conn).await { group.delete(conn).await?; } Ok(()) } pub async fn find_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { groups::table .filter(groups::organizations_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading groups") }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { groups::table .filter(groups::organizations_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_uuid_and_org(uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { groups::table .filter(groups::uuid.eq(uuid)) .filter(groups::organizations_uuid.eq(org_uuid)) .first::(conn) .ok() }} } pub async fn find_by_external_id_and_org( external_id: &str, org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { db_run! { conn: { groups::table .filter(groups::external_id.eq(external_id)) .filter(groups::organizations_uuid.eq(org_uuid)) .first::(conn) .ok() }} } //Returns all organizations the user has full access to pub async fn get_orgs_by_user_with_full_access(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { groups_users::table .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) .inner_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(groups::access_all.eq(true)) .select(groups::organizations_uuid) .distinct() .load::(conn) .expect("Error loading organization group full access information for user") }} } pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { db_run! { conn: { groups::table .inner_join(groups_users::table.on( groups_users::groups_uuid.eq(groups::uuid) )) .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::access_all.eq(true)) .select(groups::access_all) .first::(conn) .unwrap_or_default() }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { CollectionGroup::delete_all_by_group(&self.uuid, conn).await?; GroupUser::delete_all_by_group(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting group") }} } pub async fn update_revision(uuid: &GroupId, conn: &DbConn) { if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { warn!("Failed to update revision for {uuid}: {e:#?}"); } } async fn _update_revision(uuid: &GroupId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { db_run! { conn: { crate::util::retry(|| { diesel::update(groups::table.filter(groups::uuid.eq(uuid))) .set(groups::revision_date.eq(date)) .execute(conn) }, 10) .map_res("Error updating group revision") }} } } impl CollectionGroup { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } db_run! { conn: sqlite, mysql { match diesel::replace_into(collections_groups::table) .values(( collections_groups::collections_uuid.eq(&self.collections_uuid), collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), collections_groups::manage.eq(&self.manage), )) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(collections_groups::table) .filter(collections_groups::collections_uuid.eq(&self.collections_uuid)) .filter(collections_groups::groups_uuid.eq(&self.groups_uuid)) .set(( collections_groups::collections_uuid.eq(&self.collections_uuid), collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(&self.read_only), collections_groups::hide_passwords.eq(&self.hide_passwords), collections_groups::manage.eq(&self.manage), )) .execute(conn) .map_res("Error adding group to collection") } Err(e) => Err(e.into()), }.map_res("Error adding group to collection") } postgresql { diesel::insert_into(collections_groups::table) .values(( collections_groups::collections_uuid.eq(&self.collections_uuid), collections_groups::groups_uuid.eq(&self.groups_uuid), collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), collections_groups::manage.eq(self.manage), )) .on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid)) .do_update() .set(( collections_groups::read_only.eq(self.read_only), collections_groups::hide_passwords.eq(self.hide_passwords), collections_groups::manage.eq(self.manage), )) .execute(conn) .map_res("Error adding group to collection") } } } pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec { db_run! { conn: { collections_groups::table .filter(collections_groups::groups_uuid.eq(group_uuid)) .load::(conn) .expect("Error loading collection groups") }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { collections_groups::table .inner_join(groups_users::table.on( groups_users::groups_uuid.eq(collections_groups::groups_uuid) )) .inner_join(users_organizations::table.on( users_organizations::uuid.eq(groups_users::users_organizations_uuid) )) .filter(users_organizations::user_uuid.eq(user_uuid)) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading user collection groups") }} } pub async fn find_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> Vec { db_run! { conn: { collections_groups::table .filter(collections_groups::collections_uuid.eq(collection_uuid)) .select(collections_groups::all_columns) .load::(conn) .expect("Error loading collection groups") }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::collections_uuid.eq(&self.collections_uuid)) .filter(collections_groups::groups_uuid.eq(&self.groups_uuid)) .execute(conn) .map_res("Error deleting collection group") }} } pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(group_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::groups_uuid.eq(group_uuid)) .execute(conn) .map_res("Error deleting collection group") }} } pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await; for collection_assigned_to_group in collection_assigned_to_groups { let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } } db_run! { conn: { diesel::delete(collections_groups::table) .filter(collections_groups::collections_uuid.eq(collection_uuid)) .execute(conn) .map_res("Error deleting collection group") }} } } impl GroupUser { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.update_user_revision(conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(groups_users::table) .values(( groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), groups_users::groups_uuid.eq(&self.groups_uuid), )) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(groups_users::table) .filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid)) .filter(groups_users::groups_uuid.eq(&self.groups_uuid)) .set(( groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), groups_users::groups_uuid.eq(&self.groups_uuid), )) .execute(conn) .map_res("Error adding user to group") } Err(e) => Err(e.into()), }.map_res("Error adding user to group") } postgresql { diesel::insert_into(groups_users::table) .values(( groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), groups_users::groups_uuid.eq(&self.groups_uuid), )) .on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid)) .do_update() .set(( groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid), groups_users::groups_uuid.eq(&self.groups_uuid), )) .execute(conn) .map_res("Error adding user to group") } } } pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec { db_run! { conn: { groups_users::table .filter(groups_users::groups_uuid.eq(group_uuid)) .load::(conn) .expect("Error loading group users") }} } pub async fn find_by_member(member_uuid: &MembershipId, conn: &DbConn) -> Vec { db_run! { conn: { groups_users::table .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .load::(conn) .expect("Error loading groups for user") }} } pub async fn has_access_to_collection_by_member( collection_uuid: &CollectionId, member_uuid: &MembershipId, conn: &DbConn, ) -> bool { db_run! { conn: { groups_users::table .inner_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) )) .filter(collections_groups::collections_uuid.eq(collection_uuid)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .count() .first::(conn) .unwrap_or(0) != 0 }} } pub async fn has_full_access_by_member( org_uuid: &OrganizationId, member_uuid: &MembershipId, conn: &DbConn, ) -> bool { db_run! { conn: { groups_users::table .inner_join(groups::table.on( groups::uuid.eq(groups_users::groups_uuid) )) .filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::access_all.eq(true)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .count() .first::(conn) .unwrap_or(0) != 0 }} } pub async fn update_user_revision(&self, conn: &DbConn) { match Membership::find_by_uuid(&self.users_organizations_uuid, conn).await { Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await, None => warn!("Member could not be found!"), } } pub async fn delete_by_group_and_member( group_uuid: &GroupId, member_uuid: &MembershipId, conn: &DbConn, ) -> EmptyResult { match Membership::find_by_uuid(member_uuid, conn).await { Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await, None => warn!("Member could not be found!"), }; db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::groups_uuid.eq(group_uuid)) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .execute(conn) .map_res("Error deleting group users") }} } pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult { let group_users = GroupUser::find_by_group(group_uuid, conn).await; for group_user in group_users { group_user.update_user_revision(conn).await; } db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::groups_uuid.eq(group_uuid)) .execute(conn) .map_res("Error deleting group users") }} } pub async fn delete_all_by_member(member_uuid: &MembershipId, conn: &DbConn) -> EmptyResult { match Membership::find_by_uuid(member_uuid, conn).await { Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await, None => warn!("Member could not be found!"), } db_run! { conn: { diesel::delete(groups_users::table) .filter(groups_users::users_organizations_uuid.eq(member_uuid)) .execute(conn) .map_res("Error deleting user groups") }} } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct GroupId(String); ================================================ FILE: src/db/models/mod.rs ================================================ mod attachment; mod auth_request; mod cipher; mod collection; mod device; mod emergency_access; mod event; mod favorite; mod folder; mod group; mod org_policy; mod organization; mod send; mod sso_auth; mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, PushId}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; pub use self::send::{ id::{SendFileId, SendId}, Send, SendType, }; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; ================================================ FILE: src/db/models/org_policy.rs ================================================ use derive_more::{AsRef, From}; use serde::Deserialize; use serde_json::Value; use crate::api::core::two_factor; use crate::api::EmptyResult; use crate::db::schema::{org_policies, users_organizations}; use crate::db::DbConn; use crate::error::MapResult; use crate::CONFIG; use diesel::prelude::*; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = org_policies)] #[diesel(primary_key(uuid))] pub struct OrgPolicy { pub uuid: OrgPolicyId, pub org_uuid: OrganizationId, pub atype: i32, pub enabled: bool, pub data: String, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs #[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)] pub enum OrgPolicyType { TwoFactorAuthentication = 0, MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, // RequireSso = 4, // Not supported PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, ResetPassword = 8, // MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed) // DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed) // ActivateAutofill = 11, // AutomaticAppLogIn = 12, // FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, RestrictedItemTypes = 15, UriMatchDefaults = 16, // AutotypeDefaultSetting = 17, // Not supported yet // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendOptionsPolicyData { #[serde(rename = "disableHideEmail", alias = "DisableHideEmail")] pub disable_hide_email: bool, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResetPasswordDataModel { #[serde(rename = "autoEnrollEnabled", alias = "AutoEnrollEnabled")] pub auto_enroll_enabled: bool, } /// Local methods impl OrgPolicy { pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { Self { uuid: OrgPolicyId(crate::util::get_uuid()), org_uuid, atype: atype as i32, enabled, data, } } pub fn has_type(&self, policy_type: OrgPolicyType) -> bool { self.atype == policy_type as i32 } pub fn to_json(&self) -> Value { let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null); let mut policy = json!({ "id": self.uuid, "organizationId": self.org_uuid, "type": self.atype, "data": data_json, "enabled": self.enabled, "object": "policy", }); // Upstream adds this key/value // Allow enabling Single Org policy when the organization has claimed domains. // See: (https://github.com/bitwarden/server/pull/5565) // We return the same to prevent possible issues if self.atype == 8i32 { policy["canToggleState"] = json!(true); } policy } } /// Database methods impl OrgPolicy { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(org_policies::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(org_policies::table) .filter(org_policies::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving org_policy") } Err(e) => Err(e.into()), }.map_res("Error saving org_policy") } postgresql { // 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. let _: () = 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") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting org_policy") }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { org_policies::table .filter(org_policies::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading org_policy") }} } pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { org_policies::table .inner_join( users_organizations::table.on( users_organizations::org_uuid.eq(org_policies::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") }} } pub async fn find_by_org_and_type( org_uuid: &OrganizationId, policy_type: OrgPolicyType, conn: &DbConn, ) -> Option { db_run! { conn: { org_policies::table .filter(org_policies::org_uuid.eq(org_uuid)) .filter(org_policies::atype.eq(policy_type as i32)) .first::(conn) .ok() }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(org_policies::table.filter(org_policies::org_uuid.eq(org_uuid))) .execute(conn) .map_res("Error deleting org_policy") }} } pub async fn find_accepted_and_confirmed_by_user_and_active_policy( user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn, ) -> Vec { db_run! { conn: { org_policies::table .inner_join( users_organizations::table.on( users_organizations::org_uuid.eq(org_policies::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( users_organizations::status.eq(MembershipStatus::Accepted as i32) ) .or_filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") }} } pub async fn find_confirmed_by_user_and_active_policy( user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn, ) -> Vec { db_run! { conn: { org_policies::table .inner_join( users_organizations::table.on( users_organizations::org_uuid.eq(org_policies::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid))) ) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .filter(org_policies::atype.eq(policy_type as i32)) .filter(org_policies::enabled.eq(true)) .select(org_policies::all_columns) .load::(conn) .expect("Error loading org_policy") }} } /// Returns true if the user belongs to an org that has enabled the specified policy type, /// and the user is not an owner or admin of that org. This is only useful for checking /// applicability of policy types that have these particular semantics. pub async fn is_applicable_to_user( user_uuid: &UserId, policy_type: OrgPolicyType, exclude_org_uuid: Option<&OrganizationId>, conn: &DbConn, ) -> bool { for policy in OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(user_uuid, policy_type, conn).await { // Check if we need to skip this organization. if exclude_org_uuid.is_some() && *exclude_org_uuid.unwrap() == policy.org_uuid { continue; } if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { if user.atype < MembershipType::Admin { return true; } } } false } pub async fn check_user_allowed(m: &Membership, action: &str, conn: &DbConn) -> EmptyResult { if m.atype < MembershipType::Admin && m.status > (MembershipStatus::Invited as i32) { // Enforce TwoFactor/TwoStep login if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::TwoFactorAuthentication, conn).await { if p.enabled && TwoFactor::find_by_user(&m.user_uuid, conn).await.is_empty() { if CONFIG.email_2fa_auto_fallback() { two_factor::email::find_and_activate_email_2fa(&m.user_uuid, conn).await?; } else { err!(format!("Cannot {} because 2FA is required (membership {})", action, m.uuid)); } } } // Check if the user is part of another Organization with SingleOrg activated if Self::is_applicable_to_user(&m.user_uuid, OrgPolicyType::SingleOrg, Some(&m.org_uuid), conn).await { err!(format!( "Cannot {} because another organization policy forbids it (membership {})", action, m.uuid )); } if let Some(p) = Self::find_by_org_and_type(&m.org_uuid, OrgPolicyType::SingleOrg, conn).await { if p.enabled && Membership::count_accepted_and_confirmed_by_user(&m.user_uuid, &m.org_uuid, conn).await > 0 { err!(format!("Cannot {} because the organization policy forbids being part of other organization (membership {})", action, m.uuid)); } } } Ok(()) } pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool { match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await { Some(policy) => match serde_json::from_str::(&policy.data) { Ok(opts) => { return policy.enabled && opts.auto_enroll_enabled; } _ => error!("Failed to deserialize ResetPasswordDataModel: {}", policy.data), }, None => return false, } false } /// Returns true if the user belongs to an org that has enabled the `DisableHideEmail` /// option of the `Send Options` policy, and the user is not an owner or admin of that org. pub async fn is_hide_email_disabled(user_uuid: &UserId, conn: &DbConn) -> bool { for policy in OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await { if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { if user.atype < MembershipType::Admin { match serde_json::from_str::(&policy.data) { Ok(opts) => { if opts.disable_hide_email { return true; } } _ => error!("Failed to deserialize SendOptionsPolicyData: {}", policy.data), } } } } false } pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await { if let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await { return policy.enabled; } } false } } #[derive(Clone, Debug, AsRef, DieselNewType, From, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct OrgPolicyId(String); ================================================ FILE: src/db/models/organization.rs ================================================ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use num_traits::FromPrimitive; use serde_json::Value; use std::{ cmp::Ordering, collections::{HashMap, HashSet}, }; use super::{ CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, TwoFactor, User, UserId, }; use crate::db::schema::{ ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, organizations, users, users_collections, users_organizations, }; use crate::CONFIG; use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Organization { pub uuid: OrganizationId, pub name: String, pub billing_email: String, pub private_key: Option, pub public_key: Option, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = users_organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Membership { pub uuid: MembershipId, pub user_uuid: UserId, pub org_uuid: OrganizationId, pub invited_by_email: Option, pub access_all: bool, pub akey: String, pub status: i32, pub atype: i32, pub reset_password_key: Option, pub external_id: Option, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organization_api_key)] #[diesel(primary_key(uuid, org_uuid))] pub struct OrganizationApiKey { pub uuid: OrgApiKeyId, pub org_uuid: OrganizationId, pub atype: i32, pub api_key: String, pub revision_date: NaiveDateTime, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs #[derive(PartialEq)] pub enum MembershipStatus { Revoked = -1, Invited = 0, Accepted = 1, Confirmed = 2, } impl MembershipStatus { pub fn from_i32(status: i32) -> Option { match status { 0 => Some(Self::Invited), 1 => Some(Self::Accepted), 2 => Some(Self::Confirmed), // NOTE: we don't care about revoked members where this is used // if this ever changes also adapt the OrgHeaders check. _ => None, } } } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] pub enum MembershipType { Owner = 0, Admin = 1, User = 2, Manager = 3, } impl MembershipType { pub fn from_str(s: &str) -> Option { match s { "0" | "Owner" => Some(MembershipType::Owner), "1" | "Admin" => Some(MembershipType::Admin), "2" | "User" => Some(MembershipType::User), "3" | "Manager" => Some(MembershipType::Manager), // HACK: We convert the custom role to a manager role "4" | "Custom" => Some(MembershipType::Manager), _ => None, } } } impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { // For easy comparison, map each variant to an access level (where 0 is lowest). const ACCESS_LEVEL: [i32; 4] = [ 3, // Owner 2, // Admin 0, // User 1, // Manager && Custom ]; ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) } } impl PartialOrd for MembershipType { fn partial_cmp(&self, other: &MembershipType) -> Option { Some(self.cmp(other)) } } impl PartialEq for MembershipType { fn eq(&self, other: &i32) -> bool { *other == *self as i32 } } impl PartialOrd for MembershipType { fn partial_cmp(&self, other: &i32) -> Option { if let Some(other) = Self::from_i32(*other) { return Some(self.cmp(&other)); } None } fn gt(&self, other: &i32) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Greater)) } fn ge(&self, other: &i32) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Greater | Ordering::Equal)) } } impl PartialEq for i32 { fn eq(&self, other: &MembershipType) -> bool { *self == *other as i32 } } impl PartialOrd for i32 { fn partial_cmp(&self, other: &MembershipType) -> Option { if let Some(self_type) = MembershipType::from_i32(*self) { return Some(self_type.cmp(other)); } None } fn lt(&self, other: &MembershipType) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Less) | None) } fn le(&self, other: &MembershipType) -> bool { matches!(self.partial_cmp(other), Some(Ordering::Less | Ordering::Equal) | None) } } /// Local methods impl Organization { pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { let billing_email = billing_email.to_lowercase(); Self { uuid: OrganizationId(crate::util::get_uuid()), name, billing_email, private_key, public_key, } } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs pub fn to_json(&self) -> Value { json!({ "id": self.uuid, "name": self.name, "seats": null, "maxCollections": null, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side "use2fa": true, "useCustomPermissions": true, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, "useScim": false, // Not supported (Not AGPLv3 Licensed) "useSso": false, // Not supported "useKeyConnector": false, // Not supported "usePasswordManager": true, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "selfHost": true, "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "allowAdminAccessToAllCollectionItems": true, "limitCollectionCreation": true, "limitCollectionDeletion": true, "businessName": self.name, "businessAddress1": null, "businessAddress2": null, "businessAddress3": null, "businessCountry": null, "businessTaxNumber": null, "maxAutoscaleSeats": null, "maxAutoscaleSmSeats": null, "maxAutoscaleSmServiceAccounts": null, "secretsManagerPlan": null, "smSeats": null, "smServiceAccounts": null, "billingEmail": self.billing_email, "planType": 6, // Custom plan "usersGetPremium": true, "object": "organization", }) } } // Used to either subtract or add to the current status // The number 128 should be fine, it is well within the range of an i32 // The same goes for the database where we only use INTEGER (the same as an i32) // It should also provide enough room for 100+ types, which i doubt will ever happen. const ACTIVATE_REVOKE_DIFF: i32 = 128; impl Membership { pub fn new(user_uuid: UserId, org_uuid: OrganizationId, invited_by_email: Option) -> Self { Self { uuid: MembershipId(crate::util::get_uuid()), user_uuid, org_uuid, invited_by_email, access_all: false, akey: String::new(), status: MembershipStatus::Accepted as i32, atype: MembershipType::User as i32, reset_password_key: None, external_id: None, } } pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; return true; } false } pub fn revoke(&mut self) -> bool { if self.status > MembershipStatus::Revoked as i32 { self.status -= ACTIVATE_REVOKE_DIFF; return true; } false } /// Return the status of the user in an unrevoked state pub fn get_unrevoked_status(&self) -> i32 { if self.status <= MembershipStatus::Revoked as i32 { return self.status + ACTIVATE_REVOKE_DIFF; } self.status } pub fn set_external_id(&mut self, external_id: Option) -> bool { //Check if external id is empty. We don't want to have //empty strings in the database if self.external_id != external_id { self.external_id = match external_id { Some(external_id) if !external_id.is_empty() => Some(external_id), _ => None, }; return true; } false } /// HACK: Convert the manager type to a custom type /// It will be converted back on other locations pub fn type_manager_as_custom(&self) -> i32 { match self.atype { 3 => 4, _ => self.atype, } } } impl OrganizationApiKey { pub fn new(org_uuid: OrganizationId, api_key: String) -> Self { Self { uuid: OrgApiKeyId(crate::util::get_uuid()), org_uuid, atype: 0, // Type 0 is the default and only type we support currently api_key, revision_date: Utc::now().naive_utc(), } } pub fn check_valid_api_key(&self, api_key: &str) -> bool { crate::crypto::ct_eq(&self.api_key, api_key) } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; /// Database methods impl Organization { pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.billing_email) { err!(format!("BillingEmail {} is not a valid email address", self.billing_email)) } for member in Membership::find_by_org(&self.uuid, conn).await.iter() { User::update_uuid_revision(&member.user_uuid, conn).await; } db_run! { conn: sqlite, mysql { match diesel::replace_into(organizations::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(organizations::table) .filter(organizations::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving organization") } Err(e) => Err(e.into()), }.map_res("Error saving organization") } postgresql { diesel::insert_into(organizations::table) .values(self) .on_conflict(organizations::uuid) .do_update() .set(self) .execute(conn) .map_res("Error saving organization") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { use super::{Cipher, Collection}; Cipher::delete_all_by_organization(&self.uuid, conn).await?; Collection::delete_all_by_organization(&self.uuid, conn).await?; Membership::delete_all_by_organization(&self.uuid, conn).await?; OrgPolicy::delete_all_by_organization(&self.uuid, conn).await?; Group::delete_all_by_organization(&self.uuid, conn).await?; OrganizationApiKey::delete_all_by_organization(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(organizations::table.filter(organizations::uuid.eq(self.uuid))) .execute(conn) .map_res("Error saving organization") }} } pub async fn find_by_uuid(uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { organizations::table .filter(organizations::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_name(name: &str, conn: &DbConn) -> Option { db_run! { conn: { organizations::table .filter(organizations::name.eq(name)) .first::(conn) .ok() }} } pub async fn get_all(conn: &DbConn) -> Vec { db_run! { conn: { organizations::table .load::(conn) .expect("Error loading organizations") }} } pub async fn find_main_org_user_email(user_email: &str, conn: &DbConn) -> Option { let lower_mail = user_email.to_lowercase(); db_run! { conn: { organizations::table .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) .filter(users::email.eq(lower_mail)) .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) .order(users_organizations::atype.asc()) .select(organizations::all_columns) .first::(conn) .ok() }} } pub async fn find_org_user_email(user_email: &str, conn: &DbConn) -> Vec { let lower_mail = user_email.to_lowercase(); db_run! { conn: { organizations::table .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) .filter(users::email.eq(lower_mail)) .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) .order(users_organizations::atype.asc()) .select(organizations::all_columns) .load::(conn) .expect("Error loading user orgs") }} } } impl Membership { pub async fn to_json(&self, conn: &DbConn) -> Value { let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap(); // HACK: Convert the manager type to a custom type // It will be converted back on other locations let membership_type = self.type_manager_as_custom(); let permissions = json!({ // TODO: Add full support for Custom User Roles // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission "accessEventLogs": false, "accessImportExport": false, "accessReports": false, // If the following 3 Collection roles are set to true a custom user has access all permission "createNewCollections": membership_type == 4 && self.access_all, "editAnyCollection": membership_type == 4 && self.access_all, "deleteAnyCollection": membership_type == 4 && self.access_all, "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported "manageUsers": false, "manageResetPassword": false, "manageScim": false // Not supported (Not AGPLv3 Licensed) }); // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs json!({ "id": self.org_uuid, "identifier": null, // Not supported "name": org.name, "seats": 20, // hardcoded maxEmailsCount in the web-vault "maxCollections": null, "usersGetPremium": true, "use2fa": true, "useDirectory": false, // Is supported, but this value isn't checked anywhere (yet) "useEvents": CONFIG.org_events_enabled(), "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "useScim": false, // Not supported (Not AGPLv3 Licensed) "usePolicies": true, "useApi": true, "selfHost": true, "hasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), "resetPasswordEnrolled": self.reset_password_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported "useSso": false, // Not supported "useKeyConnector": false, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, "useCustomPermissions": true, "useActivateAutofillPolicy": false, "useAdminSponsoredFamilies": false, "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "organizationUserId": self.uuid, "providerId": null, "providerName": null, "providerType": null, "familySponsorshipFriendlyName": null, "familySponsorshipAvailable": false, "productTierType": 3, // Enterprise tier "keyConnectorEnabled": false, "keyConnectorUrl": null, "familySponsorshipLastSyncDate": null, "familySponsorshipValidUntil": null, "familySponsorshipToDelete": null, "accessSecretsManager": false, "limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations "limitCollectionDeletion": true, "limitItemDeletion": false, "allowAdminAccessToAllCollectionItems": true, "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization "permissions": permissions, "maxStorageGb": i16::MAX, // The value doesn't matter, we don't check server-side // These are per user "userId": self.user_uuid, "key": self.akey, "status": self.status, "type": membership_type, "enabled": true, "object": "profileOrganization", }) } pub async fn to_json_user_details(&self, include_collections: bool, include_groups: bool, conn: &DbConn) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); // Because BitWarden want the status to be -1 for revoked users we need to catch that here. // We subtract/add a number so we can restore/activate the user to it's previous state again. let status = if self.status < MembershipStatus::Revoked as i32 { MembershipStatus::Revoked as i32 } else { self.status }; let twofactor_enabled = !TwoFactor::find_by_user(&user.uuid, conn).await.is_empty(); let groups: Vec = if include_groups && CONFIG.org_groups_enabled() { GroupUser::find_by_member(&self.uuid, conn).await.iter().map(|gu| gu.groups_uuid.clone()).collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::with_capacity(0) }; // Check if a user is in a group which has access to all collections // If that is the case, we should not return individual collections! let full_access_group = CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await; // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self let collections: Vec = if include_collections && !(full_access_group || self.access_all) { // Get all collections for the user here already to prevent more queries let cu: HashMap = CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) .await .into_iter() .map(|cu| (cu.collection_uuid.clone(), cu)) .collect(); // Get all collection groups for this user to prevent there inclusion let cg: HashSet = CollectionGroup::find_by_user(&self.user_uuid, conn) .await .into_iter() .map(|cg| cg.collections_uuid) .collect(); Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) .await .into_iter() .filter_map(|c| { let (read_only, hide_passwords, manage) = if self.has_full_access() { (false, false, self.atype >= MembershipType::Manager) } else if let Some(cu) = cu.get(&c.uuid) { ( cu.read_only, cu.hide_passwords, cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), ) // If previous checks failed it might be that this user has access via a group, but we should not return those elements here // Those are returned via a special group endpoint } else if cg.contains(&c.uuid) { return None; } else { (true, true, false) }; Some(json!({ "id": c.uuid, "readOnly": read_only, "hidePasswords": hide_passwords, "manage": manage, })) }) .collect() } else { Vec::with_capacity(0) }; // HACK: Convert the manager type to a custom type // It will be converted back on other locations let membership_type = self.type_manager_as_custom(); // HACK: Only return permissions if the user is of type custom and has access_all // Else Bitwarden will assume the defaults of all false let permissions = if membership_type == 4 && self.access_all { json!({ // TODO: Add full support for Custom User Roles // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission "accessEventLogs": false, "accessImportExport": false, "accessReports": false, // If the following 3 Collection roles are set to true a custom user has access all permission "createNewCollections": true, "editAnyCollection": true, "deleteAnyCollection": true, "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported "manageUsers": false, "manageResetPassword": false, "manageScim": false // Not supported (Not AGPLv3 Licensed) }) } else { json!(null) }; json!({ "id": self.uuid, "userId": self.user_uuid, "name": if self.get_unrevoked_status() >= MembershipStatus::Accepted as i32 { Some(user.name) } else { None }, "email": user.email, "externalId": self.external_id, "avatarColor": user.avatar_color, "groups": groups, "collections": collections, "status": status, "type": membership_type, "accessAll": self.access_all, "twoFactorEnabled": twofactor_enabled, "resetPasswordEnrolled": self.reset_password_key.is_some(), "hasMasterPassword": !user.password_hash.is_empty(), "permissions": permissions, "ssoBound": false, // Not supported "managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization "claimedByOrganization": false, // Means not managed via the Members UI, like SSO "usesKeyConnector": false, // Not supported "accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "object": "organizationUserUserDetails", }) } pub fn to_json_user_access_restrictions(&self, col_user: &CollectionUser) -> Value { json!({ "id": self.uuid, "readOnly": col_user.read_only, "hidePasswords": col_user.hide_passwords, "manage": col_user.manage, }) } pub async fn to_json_details(&self, conn: &DbConn) -> Value { let coll_uuids = if self.access_all { vec![] // If we have complete access, no need to fill the array } else { let collections = CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn).await; collections .iter() .map(|cu| { json!({ "id": cu.collection_uuid, "readOnly": cu.read_only, "hidePasswords": cu.hide_passwords, "manage": cu.manage, }) }) .collect() }; // Because BitWarden want the status to be -1 for revoked users we need to catch that here. // We subtract/add a number so we can restore/activate the user to it's previous state again. let status = if self.status < MembershipStatus::Revoked as i32 { MembershipStatus::Revoked as i32 } else { self.status }; json!({ "id": self.uuid, "userId": self.user_uuid, "status": status, "type": self.atype, "accessAll": self.access_all, "collections": coll_uuids, "object": "organizationUserDetails", }) } pub async fn to_json_mini_details(&self, conn: &DbConn) -> Value { let user = User::find_by_uuid(&self.user_uuid, conn).await.unwrap(); // Because Bitwarden wants the status to be -1 for revoked users we need to catch that here. // We subtract/add a number so we can restore/activate the user to it's previous state again. let status = if self.status < MembershipStatus::Revoked as i32 { MembershipStatus::Revoked as i32 } else { self.status }; json!({ "id": self.uuid, "userId": self.user_uuid, "type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type "status": status, "name": user.name, "email": user.email, "object": "organizationUserUserMiniDetails", }) } pub async fn save(&self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: sqlite, mysql { match diesel::replace_into(users_organizations::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(users_organizations::table) .filter(users_organizations::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error adding user to organization") }, Err(e) => Err(e.into()), }.map_res("Error adding user to organization") } postgresql { diesel::insert_into(users_organizations::table) .values(self) .on_conflict(users_organizations::uuid) .do_update() .set(self) .execute(conn) .map_res("Error adding user to organization") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { User::update_uuid_revision(&self.user_uuid, conn).await; CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; GroupUser::delete_all_by_member(&self.uuid, conn).await?; db_run! { conn: { diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) .execute(conn) .map_res("Error removing user from organization") }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { for member in Self::find_by_org(org_uuid, conn).await { member.delete(conn).await?; } Ok(()) } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for member in Self::find_any_state_by_user(user_uuid, conn).await { member.delete(conn).await?; } Ok(()) } pub async fn find_by_email_and_org(email: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { if let Some(user) = User::find_by_mail(email, conn).await { if let Some(member) = Membership::find_by_user_and_org(&user.uuid, org_uuid, conn).await { return Some(member); } } None } pub fn has_status(&self, status: MembershipStatus) -> bool { self.status == status as i32 } pub fn has_type(&self, user_type: MembershipType) -> bool { self.atype == user_type as i32 } pub fn has_full_access(&self) -> bool { (self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) } pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_org(uuid: &MembershipId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::uuid.eq(uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .first::(conn) .ok() }} } pub async fn find_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .load::(conn) .unwrap_or_default() }} } pub async fn find_invited_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) .load::(conn) .unwrap_or_default() }} } // Should be used only when email are disabled. // In Organizations::send_invite status is set to Accepted only if the user has a password. pub async fn accept_user_invitations(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::update(users_organizations::table) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Invited as i32)) .set(users_organizations::status.eq(MembershipStatus::Accepted as i32)) .execute(conn) .map_res("Error confirming invitations") }} } pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .load::(conn) .unwrap_or_default() }} } pub async fn count_accepted_and_confirmed_by_user( user_uuid: &UserId, excluded_org: &OrganizationId, conn: &DbConn, ) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.ne(excluded_org)) .filter(users_organizations::status.eq(MembershipStatus::Accepted as i32).or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))) .count() .first::(conn) .unwrap_or(0) }} } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading user organizations") }} } pub async fn find_confirmed_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .load::(conn) .unwrap_or_default() }} } // Get all users which are either owner or admin, or a manager which can manage/access all pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) .or(users_organizations::atype.eq(MembershipType::Manager as i32).and(users_organizations::access_all.eq(true))) ) .load::(conn) .unwrap_or_default() }} } pub async fn count_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .count() .first::(conn) .ok() .unwrap_or(0) }} } pub async fn find_by_org_and_type(org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) .load::(conn) .expect("Error loading user organizations") }} } pub async fn count_confirmed_by_org_and_type( org_uuid: &OrganizationId, atype: MembershipType, conn: &DbConn, ) -> i64 { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::atype.eq(atype as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .count() .first::(conn) .unwrap_or(0) }} } pub async fn find_by_user_and_org(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .first::(conn) .ok() }} } pub async fn find_confirmed_by_user_and_org( user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn, ) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid)) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .first::(conn) .ok() }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading user organizations") }} } pub async fn get_orgs_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .select(users_organizations::org_uuid) .load::(conn) .unwrap_or_default() }} } pub async fn find_by_user_and_policy(user_uuid: &UserId, policy_type: OrgPolicyType, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .inner_join( org_policies::table.on( org_policies::org_uuid.eq(users_organizations::org_uuid) .and(users_organizations::user_uuid.eq(user_uuid)) .and(org_policies::atype.eq(policy_type as i32)) .and(org_policies::enabled.eq(true))) ) .filter( users_organizations::status.eq(MembershipStatus::Confirmed as i32) ) .select(users_organizations::all_columns) .load::(conn) .unwrap_or_default() }} } pub async fn find_by_cipher_and_org(cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .left_join(users_collections::table.on( users_collections::user_uuid.eq(users_organizations::user_uuid) )) .left_join(ciphers_collections::table.on( ciphers_collections::collection_uuid.eq(users_collections::collection_uuid).and( ciphers_collections::cipher_uuid.eq(&cipher_uuid) ) )) .filter( users_organizations::access_all.eq(true).or( // AccessAll.. ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection with cipher ) ) .select(users_organizations::all_columns) .distinct() .load::(conn) .expect("Error loading user organizations") }} } pub async fn find_by_cipher_and_org_with_group( cipher_uuid: &CipherId, org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .inner_join(groups_users::table.on( groups_users::users_organizations_uuid.eq(users_organizations::uuid) )) .left_join(collections_groups::table.on( collections_groups::groups_uuid.eq(groups_users::groups_uuid) )) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(ciphers_collections::table.on( ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid)) )) .filter( groups::access_all.eq(true).or( // AccessAll via groups ciphers_collections::cipher_uuid.eq(&cipher_uuid) // ..or access to collection via group ) ) .select(users_organizations::all_columns) .distinct() .load::(conn) .expect("Error loading user organizations with groups") }} } pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool { db_run! { conn: { users_organizations::table .inner_join(ciphers::table.on(ciphers::uuid.eq(cipher_uuid).and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())))) .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])) .count() .first::(conn) .ok() .unwrap_or(0) != 0 }} } pub async fn find_by_collection_and_org( collection_uuid: &CollectionId, org_uuid: &OrganizationId, conn: &DbConn, ) -> Vec { db_run! { conn: { users_organizations::table .filter(users_organizations::org_uuid.eq(org_uuid)) .left_join(users_collections::table.on( users_collections::user_uuid.eq(users_organizations::user_uuid) )) .filter( users_organizations::access_all.eq(true).or( // AccessAll.. users_collections::collection_uuid.eq(&collection_uuid) // ..or access to collection with cipher ) ) .select(users_organizations::all_columns) .load::(conn) .expect("Error loading user organizations") }} } pub async fn find_by_external_id_and_org(ext_id: &str, org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { users_organizations::table .filter( users_organizations::external_id.eq(ext_id) .and(users_organizations::org_uuid.eq(org_uuid)) ) .first::(conn) .ok() }} } pub async fn find_main_user_org(user_uuid: &str, conn: &DbConn) -> Option { db_run! { conn: { users_organizations::table .filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) .order(users_organizations::atype.asc()) .first::(conn) .ok() }} } } impl OrganizationApiKey { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(organization_api_key::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(organization_api_key::table) .filter(organization_api_key::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving organization") } Err(e) => Err(e.into()), }.map_res("Error saving organization") } postgresql { diesel::insert_into(organization_api_key::table) .values(self) .on_conflict((organization_api_key::uuid, organization_api_key::org_uuid)) .do_update() .set(self) .execute(conn) .map_res("Error saving organization") } } } pub async fn find_by_org_uuid(org_uuid: &OrganizationId, conn: &DbConn) -> Option { db_run! { conn: { organization_api_key::table .filter(organization_api_key::org_uuid.eq(org_uuid)) .first::(conn) .ok() }} } pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(organization_api_key::table.filter(organization_api_key::org_uuid.eq(org_uuid))) .execute(conn) .map_res("Error removing organization api key from organization") }} } } #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] #[deref(forward)] #[from(forward)] pub struct OrganizationId(String); #[derive( Clone, Debug, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct MembershipId(String); #[derive(Clone, Debug, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct OrgApiKeyId(String); #[cfg(test)] mod tests { use super::*; #[test] #[allow(non_snake_case)] fn partial_cmp_MembershipType() { assert!(MembershipType::Owner > MembershipType::Admin); assert!(MembershipType::Admin > MembershipType::Manager); assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } } ================================================ FILE: src/db/models/send.rs ================================================ use chrono::{NaiveDateTime, Utc}; use serde_json::Value; use crate::{config::PathType, util::LowerCase, CONFIG}; use super::{OrganizationId, User, UserId}; use crate::db::schema::sends; use diesel::prelude::*; use id::SendId; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = sends)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct Send { pub uuid: SendId, pub user_uuid: Option, pub organization_uuid: Option, pub name: String, pub notes: Option, pub atype: i32, pub data: String, pub akey: String, pub password_hash: Option>, password_salt: Option>, password_iter: Option, pub max_access_count: Option, pub access_count: i32, pub creation_date: NaiveDateTime, pub revision_date: NaiveDateTime, pub expiration_date: Option, pub deletion_date: NaiveDateTime, pub disabled: bool, pub hide_email: Option, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] pub enum SendType { Text = 0, File = 1, } impl Send { pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), user_uuid: None, organization_uuid: None, name, notes: None, atype, data, akey, password_hash: None, password_salt: None, password_iter: None, max_access_count: None, access_count: 0, creation_date: now, revision_date: now, expiration_date: None, deletion_date, disabled: false, hide_email: None, } } pub fn set_password(&mut self, password: Option<&str>) { const PASSWORD_ITER: i32 = 100_000; if let Some(password) = password { self.password_iter = Some(PASSWORD_ITER); let salt = crate::crypto::get_random_bytes::<64>().to_vec(); let hash = crate::crypto::hash_password(password.as_bytes(), &salt, PASSWORD_ITER as u32); self.password_salt = Some(salt); self.password_hash = Some(hash); } else { self.password_iter = None; self.password_salt = None; self.password_hash = None; } } pub fn check_password(&self, password: &str) -> bool { match (&self.password_hash, &self.password_salt, self.password_iter) { (Some(hash), Some(salt), Some(iter)) => { crate::crypto::verify_password_hash(password.as_bytes(), salt, hash, iter as u32) } _ => false, } } pub async fn creator_identifier(&self, conn: &DbConn) -> Option { if let Some(hide_email) = self.hide_email { if hide_email { return None; } } if let Some(user_uuid) = &self.user_uuid { if let Some(user) = User::find_by_uuid(user_uuid, conn).await { return Some(user.email); } } None } pub fn to_json(&self) -> Value { use crate::util::format_date; use data_encoding::BASE64URL_NOPAD; use uuid::Uuid; let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); // Mobile clients expect size to be a string instead of a number if let Some(size) = data.get("size").and_then(|v| v.as_i64()) { data["size"] = Value::String(size.to_string()); } json!({ "id": self.uuid, "accessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()), "type": self.atype, "name": self.name, "notes": self.notes, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, "key": self.akey, "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "disabled": self.disabled, "hideEmail": self.hide_email, "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), "deletionDate": format_date(&self.deletion_date), "object": "send", }) } pub async fn to_json_access(&self, conn: &DbConn) -> Value { use crate::util::format_date; let mut data = serde_json::from_str::>(&self.data).map(|d| d.data).unwrap_or_default(); // Mobile clients expect size to be a string instead of a number if let Some(size) = data.get("size").and_then(|v| v.as_i64()) { data["size"] = Value::String(size.to_string()); } json!({ "id": self.uuid, "type": self.atype, "name": self.name, "text": if self.atype == SendType::Text as i32 { Some(&data) } else { None }, "file": if self.atype == SendType::File as i32 { Some(&data) } else { None }, "expirationDate": self.expiration_date.as_ref().map(format_date), "creatorIdentifier": self.creator_identifier(conn).await, "object": "send-access", }) } } use crate::db::DbConn; use crate::api::EmptyResult; use crate::error::MapResult; use crate::util::NumberOrString; impl Send { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; self.revision_date = Utc::now().naive_utc(); db_run! { conn: sqlite, mysql { match diesel::replace_into(sends::table) .values(&*self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(sends::table) .filter(sends::uuid.eq(&self.uuid)) .set(&*self) .execute(conn) .map_res("Error saving send") } Err(e) => Err(e.into()), }.map_res("Error saving send") } postgresql { diesel::insert_into(sends::table) .values(&*self) .on_conflict(sends::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving send") } } } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { self.update_users_revision(conn).await; if self.atype == SendType::File as i32 { let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; operator.remove_all(&self.uuid).await.ok(); } db_run! { conn: { diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))) .execute(conn) .map_res("Error deleting send") }} } /// Purge all sends that are past their deletion date. pub async fn purge(conn: &DbConn) { for send in Self::find_by_past_deletion_date(conn).await { send.delete(conn).await.ok(); } } pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { let mut user_uuids = Vec::new(); match &self.user_uuid { Some(user_uuid) => { User::update_uuid_revision(user_uuid, conn).await; user_uuids.push(user_uuid.clone()) } None => { // Belongs to Organization, not implemented } }; user_uuids } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { for send in Self::find_by_user(user_uuid, conn).await { send.delete(conn).await?; } Ok(()) } pub async fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option { use data_encoding::BASE64URL_NOPAD; use uuid::Uuid; let Ok(uuid_vec) = BASE64URL_NOPAD.decode(access_id.as_bytes()) else { return None; }; let uuid = match Uuid::from_slice(&uuid_vec) { Ok(u) => SendId::from(u.to_string()), Err(_) => return None, }; Self::find_by_uuid(&uuid, conn).await } pub async fn find_by_uuid(uuid: &SendId, conn: &DbConn) -> Option { db_run! { conn: { sends::table .filter(sends::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_uuid_and_user(uuid: &SendId, user_uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { sends::table .filter(sends::uuid.eq(uuid)) .filter(sends::user_uuid.eq(user_uuid)) .first::(conn) .ok() }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { sends::table .filter(sends::user_uuid.eq(user_uuid)) .load::(conn) .expect("Error loading sends") }} } pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { let sends = Self::find_by_user(user_uuid, conn).await; #[derive(serde::Deserialize)] struct FileData { #[serde(rename = "size", alias = "Size")] size: NumberOrString, } let mut total: i64 = 0; for send in sends { if send.atype == SendType::File as i32 { if let Ok(size) = serde_json::from_str::(&send.data).map_err(Into::into).and_then(|d| d.size.into_i64()) { total = total.checked_add(size)?; }; } } Some(total) } pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { db_run! { conn: { sends::table .filter(sends::organization_uuid.eq(org_uuid)) .load::(conn) .expect("Error loading sends") }} } pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec { let now = Utc::now().naive_utc(); db_run! { conn: { sends::table .filter(sends::deletion_date.lt(now)) .load::(conn) .expect("Error loading sends") }} } } // separate namespace to avoid name collision with std::marker::Send pub mod id { use derive_more::{AsRef, Deref, Display, From}; use macros::{IdFromParam, UuidFromParam}; use std::marker::Send; use std::path::Path; #[derive( Clone, Debug, AsRef, Deref, DieselNewType, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, UuidFromParam, )] pub struct SendId(String); impl AsRef for SendId { #[inline] fn as_ref(&self) -> &Path { Path::new(&self.0) } } #[derive( Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam, )] pub struct SendFileId(String); impl AsRef for SendFileId { #[inline] fn as_ref(&self) -> &Path { Path::new(&self.0) } } } ================================================ FILE: src/db/models/sso_auth.rs ================================================ use chrono::{NaiveDateTime, Utc}; use std::time::Duration; use crate::api::EmptyResult; use crate::db::schema::sso_auth; use crate::db::{DbConn, DbPool}; use crate::error::MapResult; use crate::sso::{OIDCCode, OIDCCodeChallenge, OIDCIdentifier, OIDCState, SSO_AUTH_EXPIRATION}; use diesel::deserialize::FromSql; use diesel::expression::AsExpression; use diesel::prelude::*; use diesel::serialize::{Output, ToSql}; use diesel::sql_types::Text; #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[diesel(sql_type = Text)] pub enum OIDCCodeWrapper { Ok { code: OIDCCode, }, Error { error: String, error_description: Option, }, } impl_FromToSqlText!(OIDCCodeWrapper); #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[diesel(sql_type = Text)] pub struct OIDCAuthenticatedUser { pub refresh_token: Option, pub access_token: String, pub expires_in: Option, pub identifier: OIDCIdentifier, pub email: String, pub email_verified: Option, pub user_name: Option, } impl_FromToSqlText!(OIDCAuthenticatedUser); #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = sso_auth)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(state))] pub struct SsoAuth { pub state: OIDCState, pub client_challenge: OIDCCodeChallenge, pub nonce: String, pub redirect_uri: String, pub code_response: Option, pub auth_response: Option, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } /// Local methods impl SsoAuth { pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self { let now = Utc::now().naive_utc(); SsoAuth { state, client_challenge, nonce, redirect_uri, created_at: now, updated_at: now, code_response: None, auth_response: None, } } } /// Database methods impl SsoAuth { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: mysql { diesel::insert_into(sso_auth::table) .values(self) .on_conflict(diesel::dsl::DuplicatedKeys) .do_update() .set(self) .execute(conn) .map_res("Error saving SSO auth") } postgresql, sqlite { diesel::insert_into(sso_auth::table) .values(self) .on_conflict(sso_auth::state) .do_update() .set(self) .execute(conn) .map_res("Error saving SSO auth") } } } pub async fn find(state: &OIDCState, conn: &DbConn) -> Option { let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; db_run! { conn: { sso_auth::table .filter(sso_auth::state.eq(state)) .filter(sso_auth::created_at.ge(oldest)) .first::(conn) .ok() }} } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! {conn: { diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state))) .execute(conn) .map_res("Error deleting sso_auth") }} } pub async fn delete_expired(pool: DbPool) -> EmptyResult { debug!("Purging expired sso_auth"); if let Ok(conn) = pool.get().await { let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION; db_run! { conn: { diesel::delete(sso_auth::table.filter(sso_auth::created_at.lt(oldest))) .execute(conn) .map_res("Error deleting expired SSO nonce") }} } else { err!("Failed to get DB connection while purging expired sso_auth") } } } ================================================ FILE: src/db/models/two_factor.rs ================================================ use super::UserId; use crate::api::core::two_factor::webauthn::WebauthnRegistration; use crate::db::schema::twofactor; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use diesel::prelude::*; use serde_json::Value; use webauthn_rs::prelude::{Credential, ParsedAttestation}; use webauthn_rs_core::proto::CredentialV3; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor)] #[diesel(primary_key(uuid))] pub struct TwoFactor { pub uuid: TwoFactorId, pub user_uuid: UserId, pub atype: i32, pub enabled: bool, pub data: String, pub last_used: i64, } #[allow(dead_code)] #[derive(num_derive::FromPrimitive)] pub enum TwoFactorType { Authenticator = 0, Email = 1, Duo = 2, YubiKey = 3, U2f = 4, Remember = 5, OrganizationDuo = 6, Webauthn = 7, RecoveryCode = 8, // These are implementation details U2fRegisterChallenge = 1000, U2fLoginChallenge = 1001, EmailVerificationChallenge = 1002, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, // Special type for Protected Actions verification via email ProtectedActions = 2000, } /// Local methods impl TwoFactor { pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { Self { uuid: TwoFactorId(crate::util::get_uuid()), user_uuid, atype: atype as i32, enabled: true, data, last_used: 0, } } pub fn to_json(&self) -> Value { json!({ "enabled": self.enabled, "key": "", // This key and value vary "Oobject": "twoFactorAuthenticator" // This value varies }) } pub fn to_json_provider(&self) -> Value { json!({ "enabled": self.enabled, "type": self.atype, "object": "twoFactorProvider" }) } } /// Database methods impl TwoFactor { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(twofactor::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(twofactor::table) .filter(twofactor::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving twofactor") } Err(e) => Err(e.into()), }.map_res("Error saving twofactor") } postgresql { // We need to make sure we're not going to violate the unique constraint on user_uuid and atype. // This happens automatically on other DBMS backends due to replace_into(). PostgreSQL does // not support multiple constraints on ON CONFLICT clauses. let _: () = diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(&self.user_uuid)).filter(twofactor::atype.eq(&self.atype))) .execute(conn) .map_res("Error deleting twofactor for insert")?; diesel::insert_into(twofactor::table) .values(self) .on_conflict(twofactor::uuid) .do_update() .set(self) .execute(conn) .map_res("Error saving twofactor") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting twofactor") }} } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.lt(1000)) // Filter implementation types .load::(conn) .expect("Error loading twofactor") }} } pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { db_run! { conn: { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.eq(atype)) .first::(conn) .ok() }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error deleting twofactors") }} } pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult { let u2f_factors = db_run! { conn: { twofactor::table .filter(twofactor::atype.eq(TwoFactorType::U2f as i32)) .load::(conn) .expect("Error loading twofactor") }}; use crate::api::core::two_factor::webauthn::U2FRegistration; use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration}; use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy}; for mut u2f in u2f_factors { let mut regs: Vec = serde_json::from_str(&u2f.data)?; // If there are no registrations or they are migrated (we do the migration in batch so we can consider them all migrated when the first one is) if regs.is_empty() || regs[0].migrated == Some(true) { continue; } let (_, mut webauthn_regs) = get_webauthn_registrations(&u2f.user_uuid, conn).await?; // If the user already has webauthn registrations saved, don't overwrite them if !webauthn_regs.is_empty() { continue; } for reg in &mut regs { let x: [u8; 32] = reg.reg.pub_key[1..33].try_into().unwrap(); let y: [u8; 32] = reg.reg.pub_key[33..65].try_into().unwrap(); let key = COSEKey { type_: COSEAlgorithm::ES256, key: COSEKeyType::EC_EC2(COSEEC2Key { curve: ECDSACurve::SECP256R1, x: x.into(), y: y.into(), }), }; let new_reg = WebauthnRegistration { id: reg.id, migrated: true, name: reg.name.clone(), credential: Credential { counter: reg.counter, user_verified: false, cred: key, cred_id: reg.reg.key_handle.clone().into(), registration_policy: UserVerificationPolicy::Discouraged_DO_NOT_USE, transports: None, backup_eligible: false, backup_state: false, extensions: RegisteredExtensions::none(), attestation: ParsedAttestation::default(), attestation_format: AttestationFormat::None, } .into(), }; webauthn_regs.push(new_reg); reg.migrated = Some(true); } u2f.data = serde_json::to_string(®s)?; u2f.save(conn).await?; TwoFactor::new(u2f.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&webauthn_regs)?) .save(conn) .await?; } Ok(()) } pub async fn migrate_credential_to_passkey(conn: &DbConn) -> EmptyResult { let webauthn_factors = db_run! { conn: { twofactor::table .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) .load::(conn) .expect("Error loading twofactor") }}; for webauthn_factor in webauthn_factors { // assume that a failure to parse into the old struct, means that it was already converted // alternatively this could also be checked via an extra field in the db let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) else { continue; }; let regs = regs.into_iter().map(|r| r.into()).collect::>(); TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) .save(conn) .await?; } Ok(()) } } #[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TwoFactorId(String); #[derive(Deserialize)] pub struct WebauthnRegistrationV3 { pub id: i32, pub name: String, pub migrated: bool, pub credential: CredentialV3, } impl From for WebauthnRegistration { fn from(value: WebauthnRegistrationV3) -> Self { Self { id: value.id, name: value.name, migrated: value.migrated, credential: Credential::from(value.credential).into(), } } } ================================================ FILE: src/db/models/two_factor_duo_context.rs ================================================ use chrono::Utc; use crate::db::schema::twofactor_duo_ctx; use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor_duo_ctx)] #[diesel(primary_key(state))] pub struct TwoFactorDuoContext { pub state: String, pub user_email: String, pub nonce: String, pub exp: i64, } impl TwoFactorDuoContext { pub async fn find_by_state(state: &str, conn: &DbConn) -> Option { db_run! { conn: { twofactor_duo_ctx::table .filter(twofactor_duo_ctx::state.eq(state)) .first::(conn) .ok() }} } pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &DbConn) -> EmptyResult { // A saved context should never be changed, only created or deleted. let exists = Self::find_by_state(state, conn).await; if exists.is_some() { return Ok(()); }; let exp = Utc::now().timestamp() + ttl; db_run! { conn: { diesel::insert_into(twofactor_duo_ctx::table) .values(( twofactor_duo_ctx::state.eq(state), twofactor_duo_ctx::user_email.eq(user_email), twofactor_duo_ctx::nonce.eq(nonce), twofactor_duo_ctx::exp.eq(exp) )) .execute(conn) .map_res("Error saving context to twofactor_duo_ctx") }} } pub async fn find_expired(conn: &DbConn) -> Vec { let now = Utc::now().timestamp(); db_run! { conn: { twofactor_duo_ctx::table .filter(twofactor_duo_ctx::exp.lt(now)) .load::(conn) .expect("Error finding expired contexts in twofactor_duo_ctx") }} } pub async fn delete(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete( twofactor_duo_ctx::table .filter(twofactor_duo_ctx::state.eq(&self.state))) .execute(conn) .map_res("Error deleting from twofactor_duo_ctx") }} } pub async fn purge_expired_duo_contexts(conn: &DbConn) { for context in Self::find_expired(conn).await { context.delete(conn).await.ok(); } } } ================================================ FILE: src/db/models/two_factor_incomplete.rs ================================================ use chrono::{NaiveDateTime, Utc}; use crate::db::schema::twofactor_incomplete; use crate::{ api::EmptyResult, auth::ClientIp, db::{ models::{DeviceId, UserId}, DbConn, }, error::MapResult, CONFIG, }; use diesel::prelude::*; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor_incomplete)] #[diesel(primary_key(user_uuid, device_uuid))] pub struct TwoFactorIncomplete { pub user_uuid: UserId, // This device UUID is simply what's claimed by the device. It doesn't // necessarily correspond to any UUID in the devices table, since a device // must complete 2FA login before being added into the devices table. pub device_uuid: DeviceId, pub device_name: String, pub device_type: i32, pub login_time: NaiveDateTime, pub ip_address: String, } impl TwoFactorIncomplete { pub async fn mark_incomplete( user_uuid: &UserId, device_uuid: &DeviceId, device_name: &str, device_type: i32, ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } // Don't update the data for an existing user/device pair, since that // would allow an attacker to arbitrarily delay notifications by // sending repeated 2FA attempts to reset the timer. let existing = Self::find_by_user_and_device(user_uuid, device_uuid, conn).await; if existing.is_some() { return Ok(()); } db_run! { conn: { diesel::insert_into(twofactor_incomplete::table) .values(( twofactor_incomplete::user_uuid.eq(user_uuid), twofactor_incomplete::device_uuid.eq(device_uuid), twofactor_incomplete::device_name.eq(device_name), twofactor_incomplete::device_type.eq(device_type), twofactor_incomplete::login_time.eq(Utc::now().naive_utc()), twofactor_incomplete::ip_address.eq(ip.ip.to_string()), )) .execute(conn) .map_res("Error adding twofactor_incomplete record") }} } pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { return Ok(()); } Self::delete_by_user_and_device(user_uuid, device_uuid, conn).await } pub async fn find_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> Option { db_run! { conn: { twofactor_incomplete::table .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) .first::(conn) .ok() }} } pub async fn find_logins_before(dt: &NaiveDateTime, conn: &DbConn) -> Vec { db_run! { conn: { twofactor_incomplete::table .filter(twofactor_incomplete::login_time.lt(dt)) .load::(conn) .expect("Error loading twofactor_incomplete") }} } pub async fn delete(self, conn: &DbConn) -> EmptyResult { Self::delete_by_user_and_device(&self.user_uuid, &self.device_uuid, conn).await } pub async fn delete_by_user_and_device(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor_incomplete::table .filter(twofactor_incomplete::user_uuid.eq(user_uuid)) .filter(twofactor_incomplete::device_uuid.eq(device_uuid))) .execute(conn) .map_res("Error in twofactor_incomplete::delete_by_user_and_device()") }} } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(twofactor_incomplete::table.filter(twofactor_incomplete::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error in twofactor_incomplete::delete_all_by_user()") }} } } ================================================ FILE: src/db/models/user.rs ================================================ use crate::db::schema::{invitations, sso_users, twofactor_incomplete, users}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; use super::{ Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, }; use crate::{ api::EmptyResult, crypto, db::{models::DeviceId, DbConn}, error::MapResult, sso::OIDCIdentifier, util::{format_date, get_uuid, retry}, CONFIG, }; use macros::UuidFromParam; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] pub struct User { pub uuid: UserId, pub enabled: bool, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub verified_at: Option, pub last_verifying_at: Option, pub login_verify_count: i32, pub email: String, pub email_new: Option, pub email_new_token: Option, pub name: String, pub password_hash: Vec, pub salt: Vec, pub password_iterations: i32, pub password_hint: Option, pub akey: String, pub private_key: Option, pub public_key: Option, #[diesel(column_name = "totp_secret")] // Note, this is only added to the UserDb structs, not to User _totp_secret: Option, pub totp_recover: Option, pub security_stamp: String, pub stamp_exception: Option, pub equivalent_domains: String, pub excluded_globals: String, pub client_kdf_type: i32, pub client_kdf_iter: i32, pub client_kdf_memory: Option, pub client_kdf_parallelism: Option, pub api_key: Option, pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. } #[derive(Identifiable, Queryable, Insertable)] #[diesel(table_name = invitations)] #[diesel(primary_key(email))] pub struct Invitation { pub email: String, } #[derive(Identifiable, Queryable, Insertable, Selectable)] #[diesel(table_name = sso_users)] #[diesel(primary_key(user_uuid))] pub struct SsoUser { pub user_uuid: UserId, pub identifier: OIDCIdentifier, } pub enum UserKdfType { Pbkdf2 = 0, Argon2id = 1, } enum UserStatus { Enabled = 0, Invited = 1, _Disabled = 2, } #[derive(Serialize, Deserialize)] pub struct UserStampException { pub routes: Vec, pub security_stamp: String, pub expire: i64, } /// Local methods impl User { pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32; pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000; pub fn new(email: &str, name: Option) -> Self { let now = Utc::now().naive_utc(); let email = email.to_lowercase(); Self { uuid: UserId(get_uuid()), enabled: true, created_at: now, updated_at: now, verified_at: None, last_verifying_at: None, login_verify_count: 0, name: name.unwrap_or(email.clone()), email, akey: String::new(), email_new: None, email_new_token: None, password_hash: Vec::new(), salt: crypto::get_random_bytes::<64>().to_vec(), password_iterations: CONFIG.password_iterations(), security_stamp: get_uuid(), stamp_exception: None, password_hint: None, private_key: None, public_key: None, _totp_secret: None, totp_recover: None, equivalent_domains: "[]".to_string(), excluded_globals: "[]".to_string(), client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT, client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT, client_kdf_memory: None, client_kdf_parallelism: None, api_key: None, avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. } } pub fn check_valid_password(&self, password: &str) -> bool { crypto::verify_password_hash( password.as_bytes(), &self.salt, &self.password_hash, self.password_iterations as u32, ) } pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { if let Some(ref totp_recover) = self.totp_recover { crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) } else { false } } pub fn check_valid_api_key(&self, key: &str) -> bool { matches!(self.api_key, Some(ref api_key) if crypto::ct_eq(api_key, key)) } /// Set the password hash generated /// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different. /// /// # Arguments /// /// * `password` - A str which contains a hashed version of the users master password. /// * `new_key` - A String which contains the new aKey value of the users master password. /// * `allow_next_route` - A Option> with the function names of the next allowed (rocket) routes. /// These routes are able to use the previous stamp id for the next 2 minutes. /// After these 2 minutes this stamp will expire. /// pub fn set_password( &mut self, password: &str, new_key: Option, reset_security_stamp: bool, allow_next_route: Option>, ) { self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32); if let Some(route) = allow_next_route { self.set_stamp_exception(route); } if let Some(new_key) = new_key { self.akey = new_key; } if reset_security_stamp { self.reset_security_stamp() } } pub fn reset_security_stamp(&mut self) { self.security_stamp = get_uuid(); } /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// /// # Arguments /// * `route_exception` - A Vec with the function names of the next allowed (rocket) routes. /// These routes are able to use the previous stamp id for the next 2 minutes. /// After these 2 minutes this stamp will expire. /// pub fn set_stamp_exception(&mut self, route_exception: Vec) { let stamp_exception = UserStampException { routes: route_exception, security_stamp: self.security_stamp.clone(), expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(), }; self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default()); } /// Resets the stamp_exception to prevent re-use of the previous security-stamp pub fn reset_stamp_exception(&mut self) { self.stamp_exception = None; } pub fn display_name(&self) -> &str { // default to email if name is empty if !&self.name.is_empty() { &self.name } else { &self.email } } } /// Database methods impl User { pub async fn to_json(&self, conn: &DbConn) -> Value { let mut orgs_json = Vec::new(); for c in Membership::find_confirmed_by_user(&self.uuid, conn).await { orgs_json.push(c.to_json(conn).await); } let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty(); // TODO: Might want to save the status field in the DB let status = if self.password_hash.is_empty() { UserStatus::Invited } else { UserStatus::Enabled }; json!({ "_status": status as i32, "id": self.uuid, "name": self.name, "email": self.email, "emailVerified": !CONFIG.mail_enabled() || self.verified_at.is_some(), "premium": true, "premiumFromOrganization": false, "culture": "en-US", "twoFactorEnabled": twofactor_enabled, "key": self.akey, "privateKey": self.private_key, "securityStamp": self.security_stamp, "organizations": orgs_json, "providers": [], "providerOrganizations": [], "forcePasswordReset": false, "avatarColor": self.avatar_color, "usesKeyConnector": false, "creationDate": format_date(&self.created_at), "object": "profile", }) } pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { err!(format!("User email {} is not a valid email address", self.email)) } self.updated_at = Utc::now().naive_utc(); db_run! { conn: mysql { diesel::insert_into(users::table) .values(&*self) .on_conflict(diesel::dsl::DuplicatedKeys) .do_update() .set(&*self) .execute(conn) .map_res("Error saving user") } postgresql, sqlite { diesel::insert_into(users::table) // Insert or update .values(&*self) .on_conflict(users::uuid) .do_update() .set(&*self) .execute(conn) .map_res("Error saving user") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await { if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1 { err!("Can't delete last owner") } } super::Send::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?; Membership::delete_all_by_user(&self.uuid, conn).await?; Cipher::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; Invitation::take(&self.email, conn).await; // Delete invitation if any db_run! { conn: { diesel::delete(users::table.filter(users::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting user") }} } pub async fn update_uuid_revision(uuid: &UserId, conn: &DbConn) { if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await { warn!("Failed to update revision for {uuid}: {e:#?}"); } } pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult { let updated_at = Utc::now().naive_utc(); db_run! { conn: { retry(|| { diesel::update(users::table) .set(users::updated_at.eq(updated_at)) .execute(conn) }, 10) .map_res("Error updating revision date for all users") }} } pub async fn update_revision(&mut self, conn: &DbConn) -> EmptyResult { self.updated_at = Utc::now().naive_utc(); Self::_update_revision(&self.uuid, &self.updated_at, conn).await } async fn _update_revision(uuid: &UserId, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { db_run! { conn: { retry(|| { diesel::update(users::table.filter(users::uuid.eq(uuid))) .set(users::updated_at.eq(date)) .execute(conn) }, 10) .map_res("Error updating user revision") }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { let lower_mail = mail.to_lowercase(); db_run! { conn: { users::table .filter(users::email.eq(lower_mail)) .first::(conn) .ok() }} } pub async fn find_by_uuid(uuid: &UserId, conn: &DbConn) -> Option { db_run! { conn: { users::table .filter(users::uuid.eq(uuid)) .first::(conn) .ok() }} } pub async fn find_by_device_for_email2fa(device_uuid: &DeviceId, conn: &DbConn) -> Option { if let Some(user_uuid) = db_run! ( conn: { twofactor_incomplete::table .filter(twofactor_incomplete::device_uuid.eq(device_uuid)) .order_by(twofactor_incomplete::login_time.desc()) .select(twofactor_incomplete::user_uuid) .first::(conn) .ok() }) { return Self::find_by_uuid(&user_uuid, conn).await; } None } pub async fn get_all(conn: &DbConn) -> Vec<(Self, Option)> { db_run! { conn: { users::table .left_join(sso_users::table) .select(<(Self, Option)>::as_select()) .load(conn) .expect("Error loading groups for user") .into_iter() .collect() }} } pub async fn last_active(&self, conn: &DbConn) -> Option { match Device::find_latest_active_by_user(&self.uuid, conn).await { Some(device) => Some(device.updated_at), None => None, } } } impl Invitation { pub fn new(email: &str) -> Self { let email = email.to_lowercase(); Self { email, } } pub async fn save(&self, conn: &DbConn) -> EmptyResult { if !crate::util::is_valid_email(&self.email) { err!(format!("Invitation email {} is not a valid email address", self.email)) } db_run! { conn: sqlite, mysql { // Not checking for ForeignKey Constraints here // Table invitations does not have any ForeignKey Constraints. diesel::replace_into(invitations::table) .values(self) .execute(conn) .map_res("Error saving invitation") } postgresql { diesel::insert_into(invitations::table) .values(self) .on_conflict(invitations::email) .do_nothing() .execute(conn) .map_res("Error saving invitation") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(invitations::table.filter(invitations::email.eq(self.email))) .execute(conn) .map_res("Error deleting invitation") }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option { let lower_mail = mail.to_lowercase(); db_run! { conn: { invitations::table .filter(invitations::email.eq(lower_mail)) .first::(conn) .ok() }} } pub async fn take(mail: &str, conn: &DbConn) -> bool { match Self::find_by_mail(mail, conn).await { Some(invitation) => invitation.delete(conn).await.is_ok(), None => false, } } } #[derive( Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, UuidFromParam, )] #[deref(forward)] #[from(forward)] pub struct UserId(String); impl SsoUser { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { diesel::replace_into(sso_users::table) .values(self) .execute(conn) .map_res("Error saving SSO user") } postgresql { diesel::insert_into(sso_users::table) .values(self) .execute(conn) .map_res("Error saving SSO user") } } } pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, Self)> { db_run! { conn: { users::table .inner_join(sso_users::table) .select(<(User, Self)>::as_select()) .filter(sso_users::identifier.eq(identifier)) .first::<(User, Self)>(conn) .ok() }} } pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option)> { let lower_mail = mail.to_lowercase(); db_run! { conn: { users::table .left_join(sso_users::table) .select(<(User, Option)>::as_select()) .filter(users::email.eq(lower_mail)) .first::<(User, Option)>(conn) .ok() }} } pub async fn delete(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { db_run! { conn: { diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error deleting sso user") }} } } ================================================ FILE: src/db/query_logger.rs ================================================ use diesel::connection::{Instrumentation, InstrumentationEvent}; use std::{cell::RefCell, collections::HashMap, time::Instant}; thread_local! { static QUERY_PERF_TRACKER: RefCell> = RefCell::new(HashMap::new()); } pub fn simple_logger() -> Option> { Some(Box::new(|event: InstrumentationEvent<'_>| match event { InstrumentationEvent::StartEstablishConnection { url, .. } => { debug!("Establishing connection: {url}") } InstrumentationEvent::FinishEstablishConnection { url, error, .. } => { if let Some(e) = error { error!("Error during establishing a connection with {url}: {e:?}") } else { debug!("Connection established: {url}") } } InstrumentationEvent::StartQuery { query, .. } => { let query_string = format!("{query:?}"); let start = Instant::now(); QUERY_PERF_TRACKER.with_borrow_mut(|map| { map.insert(query_string, start); }); } InstrumentationEvent::FinishQuery { query, .. } => { let query_string = format!("{query:?}"); QUERY_PERF_TRACKER.with_borrow_mut(|map| { if let Some(start) = map.remove(&query_string) { let duration = start.elapsed(); if duration.as_secs() >= 5 { warn!("SLOW QUERY [{:.2}s]: {}", duration.as_secs_f32(), query_string); } else if duration.as_secs() >= 1 { info!("SLOW QUERY [{:.2}s]: {}", duration.as_secs_f32(), query_string); } else { debug!("QUERY [{:?}]: {}", duration, query_string); } } }); } _ => {} })) } ================================================ FILE: src/db/schema.rs ================================================ table! { attachments (id) { id -> Text, cipher_uuid -> Text, file_name -> Text, file_size -> BigInt, akey -> Nullable, } } table! { ciphers (uuid) { uuid -> Text, created_at -> Timestamp, updated_at -> Timestamp, user_uuid -> Nullable, organization_uuid -> Nullable, key -> Nullable, atype -> Integer, name -> Text, notes -> Nullable, fields -> Nullable, data -> Text, password_history -> Nullable, deleted_at -> Nullable, reprompt -> Nullable, } } table! { ciphers_collections (cipher_uuid, collection_uuid) { cipher_uuid -> Text, collection_uuid -> Text, } } table! { collections (uuid) { uuid -> Text, org_uuid -> Text, name -> Text, external_id -> Nullable, } } table! { devices (uuid, user_uuid) { uuid -> Text, created_at -> Timestamp, updated_at -> Timestamp, user_uuid -> Text, name -> Text, atype -> Integer, push_uuid -> Nullable, push_token -> Nullable, refresh_token -> Text, twofactor_remember -> Nullable, } } table! { event (uuid) { uuid -> Text, event_type -> Integer, user_uuid -> Nullable, org_uuid -> Nullable, cipher_uuid -> Nullable, collection_uuid -> Nullable, group_uuid -> Nullable, org_user_uuid -> Nullable, act_user_uuid -> Nullable, device_type -> Nullable, ip_address -> Nullable, event_date -> Timestamp, policy_uuid -> Nullable, provider_uuid -> Nullable, provider_user_uuid -> Nullable, provider_org_uuid -> Nullable, } } table! { favorites (user_uuid, cipher_uuid) { user_uuid -> Text, cipher_uuid -> Text, } } table! { folders (uuid) { uuid -> Text, created_at -> Timestamp, updated_at -> Timestamp, user_uuid -> Text, name -> Text, } } table! { folders_ciphers (cipher_uuid, folder_uuid) { cipher_uuid -> Text, folder_uuid -> Text, } } table! { invitations (email) { email -> Text, } } table! { org_policies (uuid) { uuid -> Text, org_uuid -> Text, atype -> Integer, enabled -> Bool, data -> Text, } } table! { organizations (uuid) { uuid -> Text, name -> Text, billing_email -> Text, private_key -> Nullable, public_key -> Nullable, } } table! { sends (uuid) { uuid -> Text, user_uuid -> Nullable, organization_uuid -> Nullable, name -> Text, notes -> Nullable, atype -> Integer, data -> Text, akey -> Text, password_hash -> Nullable, password_salt -> Nullable, password_iter -> Nullable, max_access_count -> Nullable, access_count -> Integer, creation_date -> Timestamp, revision_date -> Timestamp, expiration_date -> Nullable, deletion_date -> Timestamp, disabled -> Bool, hide_email -> Nullable, } } table! { twofactor (uuid) { uuid -> Text, user_uuid -> Text, atype -> Integer, enabled -> Bool, data -> Text, last_used -> BigInt, } } table! { twofactor_incomplete (user_uuid, device_uuid) { user_uuid -> Text, device_uuid -> Text, device_name -> Text, device_type -> Integer, login_time -> Timestamp, ip_address -> Text, } } table! { twofactor_duo_ctx (state) { state -> Text, user_email -> Text, nonce -> Text, exp -> BigInt, } } table! { users (uuid) { uuid -> Text, enabled -> Bool, created_at -> Timestamp, updated_at -> Timestamp, verified_at -> Nullable, last_verifying_at -> Nullable, login_verify_count -> Integer, email -> Text, email_new -> Nullable, email_new_token -> Nullable, name -> Text, password_hash -> Binary, salt -> Binary, password_iterations -> Integer, password_hint -> Nullable, akey -> Text, private_key -> Nullable, public_key -> Nullable, totp_secret -> Nullable, totp_recover -> Nullable, security_stamp -> Text, stamp_exception -> Nullable, equivalent_domains -> Text, excluded_globals -> Text, client_kdf_type -> Integer, client_kdf_iter -> Integer, client_kdf_memory -> Nullable, client_kdf_parallelism -> Nullable, api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, } } table! { users_collections (user_uuid, collection_uuid) { user_uuid -> Text, collection_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, manage -> Bool, } } table! { users_organizations (uuid) { uuid -> Text, user_uuid -> Text, org_uuid -> Text, invited_by_email -> Nullable, access_all -> Bool, akey -> Text, status -> Integer, atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, } } table! { organization_api_key (uuid, org_uuid) { uuid -> Text, org_uuid -> Text, atype -> Integer, api_key -> Text, revision_date -> Timestamp, } } table! { sso_auth (state) { state -> Text, client_challenge -> Text, nonce -> Text, redirect_uri -> Text, code_response -> Nullable, auth_response -> Nullable, created_at -> Timestamp, updated_at -> Timestamp, } } table! { sso_users (user_uuid) { user_uuid -> Text, identifier -> Text, } } table! { emergency_access (uuid) { uuid -> Text, grantor_uuid -> Text, grantee_uuid -> Nullable, email -> Nullable, key_encrypted -> Nullable, atype -> Integer, status -> Integer, wait_time_days -> Integer, recovery_initiated_at -> Nullable, last_notification_at -> Nullable, updated_at -> Timestamp, created_at -> Timestamp, } } table! { groups (uuid) { uuid -> Text, organizations_uuid -> Text, name -> Text, access_all -> Bool, external_id -> Nullable, creation_date -> Timestamp, revision_date -> Timestamp, } } table! { groups_users (groups_uuid, users_organizations_uuid) { groups_uuid -> Text, users_organizations_uuid -> Text, } } table! { collections_groups (collections_uuid, groups_uuid) { collections_uuid -> Text, groups_uuid -> Text, read_only -> Bool, hide_passwords -> Bool, manage -> Bool, } } table! { auth_requests (uuid) { uuid -> Text, user_uuid -> Text, organization_uuid -> Nullable, request_device_identifier -> Text, device_type -> Integer, request_ip -> Text, response_device_id -> Nullable, access_code -> Text, public_key -> Text, enc_key -> Nullable, master_password_hash -> Nullable, approved -> Nullable, creation_date -> Timestamp, response_date -> Nullable, authentication_date -> Nullable, } } joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); joinable!(ciphers_collections -> ciphers (cipher_uuid)); joinable!(ciphers_collections -> collections (collection_uuid)); joinable!(collections -> organizations (org_uuid)); 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!(sends -> organizations (organization_uuid)); joinable!(sends -> users (user_uuid)); joinable!(twofactor -> users (user_uuid)); joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(users_organizations -> ciphers (org_uuid)); joinable!(organization_api_key -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); joinable!(groups -> organizations (organizations_uuid)); joinable!(groups_users -> users_organizations (users_organizations_uuid)); joinable!(groups_users -> groups (groups_uuid)); joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); joinable!(auth_requests -> users (user_uuid)); joinable!(sso_users -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, ciphers, ciphers_collections, collections, devices, folders, folders_ciphers, invitations, org_policies, organizations, sends, sso_users, twofactor, users, users_collections, users_organizations, organization_api_key, emergency_access, groups, groups_users, collections_groups, event, auth_requests, ); ================================================ FILE: src/error.rs ================================================ // // Error generator macro // use crate::db::models::EventType; use crate::http_client::CustomHttpClientError; use serde::ser::{Serialize, SerializeStruct, Serializer}; use std::error::Error as StdError; macro_rules! make_error { ( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => { const BAD_REQUEST: u16 = 400; pub enum ErrorKind { $($name( $ty )),+ } #[derive(Debug)] pub struct ErrorEvent { pub event: EventType } pub struct Error { message: String, error: ErrorKind, error_code: u16, event: Option } $(impl From<$ty> for Error { fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } })+ $(impl> From<(S, $ty)> for Error { fn from(val: (S, $ty)) -> Self { Error { message: val.0.into(), error: ErrorKind::$name(val.1), error_code: BAD_REQUEST, event: None } } })+ impl StdError for Error { fn source(&self) -> Option<&(dyn StdError + 'static)> { match &self.error {$( ErrorKind::$name(e) => $src_fn(e), )+} } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.error {$( ErrorKind::$name(e) => f.write_str(&$usr_msg_fun(e, &self.message)), )+} } } }; } use diesel::r2d2::Error as R2d2Err; use diesel::r2d2::PoolError as R2d2PoolErr; use diesel::result::Error as DieselErr; use diesel::ConnectionError as DieselConErr; use handlebars::RenderError as HbErr; use jsonwebtoken::errors::Error as JwtErr; use lettre::address::AddressError as AddrErr; use lettre::error::Error as LettreErr; use lettre::transport::smtp::Error as SmtpErr; use opendal::Error as OpenDALErr; use openssl::error::ErrorStack as SSLErr; use regex::Error as RegexErr; use reqwest::Error as ReqErr; use rocket::error::Error as RocketErr; use serde_json::{Error as SerdeErr, Value}; use std::io::Error as IoErr; use std::time::SystemTimeError as TimeErr; use webauthn_rs::prelude::WebauthnError as WebauthnErr; use yubico::yubicoerror::YubicoError as YubiErr; #[derive(Serialize)] pub struct Empty {} pub struct Compact {} // Error struct // Contains a String error message, meant for the user and an enum variant, with an error of different types. // // After the variant itself, there are two expressions. The first one indicates whether the error contains a source error (that we pretty print). // The second one contains the function used to obtain the response sent to the client make_error! { // Just an empty error Empty(Empty): _no_source, _serialize, // Used to represent err! calls Simple(String): _no_source, _api_error, Compact(Compact): _no_source, _compact_api_error, // Used in our custom http client to handle non-global IPs and blocked domains CustomHttpClient(CustomHttpClientError): _has_source, _api_error, // Used for special return values, like 2FA errors Json(Value): _no_source, _serialize, Db(DieselErr): _has_source, _api_error, R2d2(R2d2Err): _has_source, _api_error, R2d2Pool(R2d2PoolErr): _has_source, _api_error, Serde(SerdeErr): _has_source, _api_error, JWt(JwtErr): _has_source, _api_error, Handlebars(HbErr): _has_source, _api_error, Io(IoErr): _has_source, _api_error, Time(TimeErr): _has_source, _api_error, Req(ReqErr): _has_source, _api_error, Regex(RegexErr): _has_source, _api_error, Yubico(YubiErr): _has_source, _api_error, Lettre(LettreErr): _has_source, _api_error, Address(AddrErr): _has_source, _api_error, Smtp(SmtpErr): _has_source, _api_error, OpenSSL(SSLErr): _has_source, _api_error, Rocket(RocketErr): _has_source, _api_error, DieselCon(DieselConErr): _has_source, _api_error, Webauthn(WebauthnErr): _has_source, _api_error, OpenDAL(OpenDALErr): _has_source, _api_error, } impl std::fmt::Debug for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.source() { Some(e) => write!(f, "{}.\n[CAUSE] {:#?}", self.message, e), None => match self.error { ErrorKind::Empty(_) => Ok(()), ErrorKind::Simple(ref s) => { if &self.message == s { write!(f, "{}", self.message) } else { write!(f, "{}. {}", self.message, s) } } ErrorKind::Json(_) => write!(f, "{}", self.message), _ => unreachable!(), }, } } } impl Error { pub fn new, N: Into>(usr_msg: M, log_msg: N) -> Self { (usr_msg, log_msg.into()).into() } pub fn new_msg + Clone>(usr_msg: M) -> Self { (usr_msg.clone(), usr_msg.into()).into() } pub fn empty() -> Self { Empty {}.into() } #[must_use] pub fn with_msg>(mut self, msg: M) -> Self { self.message = msg.into(); self } #[must_use] pub fn with_kind(mut self, kind: ErrorKind) -> Self { self.error = kind; self } #[must_use] pub const fn with_code(mut self, code: u16) -> Self { self.error_code = code; self } #[must_use] pub fn with_event(mut self, event: ErrorEvent) -> Self { self.event = Some(event); self } pub fn get_event(&self) -> &Option { &self.event } pub fn message(&self) -> &str { &self.message } } pub trait MapResult { fn map_res(self, msg: &str) -> Result; } impl> MapResult for Result { fn map_res(self, msg: &str) -> Result { self.map_err(|e| e.into().with_msg(msg)) } } impl> MapResult<()> for Result { fn map_res(self, msg: &str) -> Result<(), Error> { self.and(Ok(())).map_res(msg) } } impl MapResult for Option { fn map_res(self, msg: &str) -> Result { self.ok_or_else(|| Error::new(msg, "")) } } const fn _has_source(e: T) -> Option { Some(e) } fn _no_source(_: T) -> Option { None } fn _serialize(e: &impl Serialize, _msg: &str) -> String { serde_json::to_string(e).unwrap() } /// This will serialize the default ApiErrorResponse /// It will add the needed fields which are mostly empty or have multiple copies of the message /// This is more efficient than having a larger struct and use the Serialize derive /// It also prevents using `json!()` calls to create the final output impl Serialize for ApiErrorResponse<'_> { fn serialize(&self, serializer: S) -> Result where S: Serializer, { #[derive(serde::Serialize)] struct ErrorModel<'a> { message: &'a str, object: &'static str, } let mut state = serializer.serialize_struct("ApiErrorResponse", 9)?; state.serialize_field("message", self.0.message)?; let mut validation_errors = std::collections::HashMap::with_capacity(1); validation_errors.insert("", vec![self.0.message]); state.serialize_field("validationErrors", &validation_errors)?; let error_model = ErrorModel { message: self.0.message, object: "error", }; state.serialize_field("errorModel", &error_model)?; state.serialize_field("error", "")?; state.serialize_field("error_description", "")?; state.serialize_field("exceptionMessage", &None::<()>)?; state.serialize_field("exceptionStackTrace", &None::<()>)?; state.serialize_field("innerExceptionMessage", &None::<()>)?; state.serialize_field("object", "error")?; state.end() } } /// This will serialize the smaller CompactApiErrorResponse /// It will add the needed fields which are mostly empty /// This is more efficient than having a larger struct and use the Serialize derive /// It also prevents using `json!()` calls to create the final output impl Serialize for CompactApiErrorResponse<'_> { fn serialize(&self, serializer: S) -> Result where S: Serializer, { let mut state = serializer.serialize_struct("CompactApiErrorResponse", 6)?; state.serialize_field("message", self.0.message)?; state.serialize_field("validationErrors", &None::<()>)?; state.serialize_field("exceptionMessage", &None::<()>)?; state.serialize_field("exceptionStackTrace", &None::<()>)?; state.serialize_field("innerExceptionMessage", &None::<()>)?; state.serialize_field("object", "error")?; state.end() } } /// Main API Error struct template /// This struct which we can be used by both ApiErrorResponse and CompactApiErrorResponse /// is small and doesn't contain unneeded empty fields. This is more memory efficient, but also less code to compile struct ApiErrorMsg<'a> { message: &'a str, } /// Default API Error response struct /// The custom serialization adds all other needed fields struct ApiErrorResponse<'a>(ApiErrorMsg<'a>); /// Compact API Error response struct used for some newer error responses /// The custom serialization adds all other needed fields struct CompactApiErrorResponse<'a>(ApiErrorMsg<'a>); fn _api_error(_: &impl std::any::Any, msg: &str) -> String { let response = ApiErrorMsg { message: msg, }; serde_json::to_string(&ApiErrorResponse(response)).unwrap() } fn _compact_api_error(_: &impl std::any::Any, msg: &str) -> String { let response = ApiErrorMsg { message: msg, }; serde_json::to_string(&CompactApiErrorResponse(response)).unwrap() } // // Rocket responder impl // use std::io::Cursor; use rocket::http::{ContentType, Status}; use rocket::request::Request; use rocket::response::{self, Responder, Response}; impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { match self.error { ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation _ => error!(target: "error", "{self:#?}"), }; let code = Status::from_code(self.error_code).unwrap_or(Status::BadRequest); let body = self.to_string(); Response::build().status(code).header(ContentType::JSON).sized_body(Some(body.len()), Cursor::new(body)).ok() } } // // Error return macros // #[macro_export] macro_rules! err { ($kind:ident, $msg:expr) => {{ let msg = $msg; error!("{msg}"); return Err($crate::error::Error::new_msg(msg).with_kind($crate::error::ErrorKind::$kind($crate::error::$kind {}))); }}; ($msg:expr) => {{ let msg = $msg; error!("{msg}"); return Err($crate::error::Error::new_msg(msg)); }}; ($msg:expr, ErrorEvent $err_event:tt) => {{ let msg = $msg; error!("{msg}"); return Err($crate::error::Error::new_msg(msg).with_event($crate::error::ErrorEvent $err_event)); }}; ($usr_msg:expr, $log_value:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!("{usr_msg}. {log_value}"); return Err($crate::error::Error::new(usr_msg, log_value)); }}; ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!("{usr_msg}. {log_value}"); return Err($crate::error::Error::new(usr_msg, log_value).with_event($crate::error::ErrorEvent $err_event)); }}; } #[macro_export] macro_rules! err_silent { ($msg:expr) => {{ return Err($crate::error::Error::new_msg($msg)); }}; ($msg:expr, ErrorEvent $err_event:tt) => {{ return Err($crate::error::Error::new_msg($msg).with_event($crate::error::ErrorEvent $err_event)); }}; ($usr_msg:expr, $log_value:expr) => {{ return Err($crate::error::Error::new($usr_msg, $log_value)); }}; ($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{ return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event)); }}; } #[macro_export] macro_rules! err_code { ($msg:expr, $err_code:expr) => {{ let msg = $msg; error!("{msg}"); return Err($crate::error::Error::new_msg(msg).with_code($err_code)); }}; ($usr_msg:expr, $log_value:expr, $err_code:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!("{usr_msg}. {log_value}"); return Err($crate::error::Error::new(usr_msg, log_value).with_code($err_code)); }}; } #[macro_export] macro_rules! err_discard { ($msg:expr, $data:expr) => {{ std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); return Err($crate::error::Error::new_msg($msg)); }}; ($usr_msg:expr, $log_value:expr, $data:expr) => {{ std::io::copy(&mut $data.open(), &mut std::io::sink()).ok(); return Err($crate::error::Error::new($usr_msg, $log_value)); }}; } #[macro_export] macro_rules! err_json { ($expr:expr, $log_value:expr) => {{ return Err(($log_value, $expr).into()); }}; ($expr:expr, $log_value:expr, $err_event:expr, ErrorEvent) => {{ return Err(($log_value, $expr).into().with_event($err_event)); }}; } #[macro_export] macro_rules! err_handler { ($expr:expr) => {{ error!(target: "auth", "Unauthorized Error: {}", $expr); return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr)); }}; ($usr_msg:expr, $log_value:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}"); return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg)); }}; } ================================================ FILE: src/http_client.rs ================================================ use std::{ fmt, net::{IpAddr, SocketAddr}, str::FromStr, sync::{Arc, LazyLock, Mutex}, time::Duration, }; use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver}; use regex::Regex; use reqwest::{ dns::{Name, Resolve, Resolving}, header, Client, ClientBuilder, }; use url::Host; use crate::{util::is_global, CONFIG}; pub fn make_http_request(method: reqwest::Method, url: &str) -> Result { let Ok(url) = url::Url::parse(url) else { err!("Invalid URL"); }; let Some(host) = url.host() else { err!("Invalid host"); }; should_block_host(&host)?; static INSTANCE: LazyLock = LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); Ok(INSTANCE.request(method, url)) } pub fn get_reqwest_client_builder() -> ClientBuilder { let mut headers = header::HeaderMap::new(); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { if attempt.previous().len() >= 5 { return attempt.error("Too many redirects"); } let Some(host) = attempt.url().host() else { return attempt.error("Invalid host"); }; if let Err(e) = should_block_host(&host) { return attempt.error(e); } attempt.follow() }); Client::builder() .default_headers(headers) .redirect(redirect_policy) .dns_resolver(CustomDnsResolver::instance()) .timeout(Duration::from_secs(10)) } pub fn should_block_address(domain_or_ip: &str) -> bool { if let Ok(ip) = IpAddr::from_str(domain_or_ip) { if should_block_ip(ip) { return true; } } should_block_address_regex(domain_or_ip) } fn should_block_ip(ip: IpAddr) -> bool { if !CONFIG.http_request_block_non_global_ips() { return false; } !is_global(ip) } fn should_block_address_regex(domain_or_ip: &str) -> bool { let Some(block_regex) = CONFIG.http_request_block_regex() else { return false; }; static COMPILED_REGEX: Mutex> = Mutex::new(None); let mut guard = COMPILED_REGEX.lock().unwrap(); // If the stored regex is up to date, use it if let Some((value, regex)) = &*guard { if value == &block_regex { return regex.is_match(domain_or_ip); } } // If we don't have a regex stored, or it's not up to date, recreate it let regex = Regex::new(&block_regex).unwrap(); let is_match = regex.is_match(domain_or_ip); *guard = Some((block_regex, regex)); is_match } fn should_block_host(host: &Host<&str>) -> Result<(), CustomHttpClientError> { let (ip, host_str): (Option, String) = match host { Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()), Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()), Host::Domain(d) => (None, (*d).to_string()), }; if let Some(ip) = ip { if should_block_ip(ip) { return Err(CustomHttpClientError::NonGlobalIp { domain: None, ip, }); } } if should_block_address_regex(&host_str) { return Err(CustomHttpClientError::Blocked { domain: host_str, }); } Ok(()) } #[derive(Debug, Clone)] pub enum CustomHttpClientError { Blocked { domain: String, }, NonGlobalIp { domain: Option, ip: IpAddr, }, } impl CustomHttpClientError { pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { let mut source = e.source(); while let Some(err) = source { source = err.source(); if let Some(err) = err.downcast_ref::() { return Some(err); } } None } } impl fmt::Display for CustomHttpClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Blocked { domain, } => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), Self::NonGlobalIp { domain: Some(domain), ip, } => write!(f, "IP {ip} for domain '{domain}' is not a global IP!"), Self::NonGlobalIp { domain: None, ip, } => write!(f, "IP {ip} is not a global IP!"), } } } impl std::error::Error for CustomHttpClientError {} #[derive(Debug, Clone)] enum CustomDnsResolver { Default(), Hickory(Arc), } type BoxError = Box; impl CustomDnsResolver { fn instance() -> Arc { static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); Arc::clone(&*INSTANCE) } fn new() -> Arc { match TokioResolver::builder(TokioConnectionProvider::default()) { Ok(mut builder) => { if CONFIG.dns_prefer_ipv6() { builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4; } let resolver = builder.build(); Arc::new(Self::Hickory(Arc::new(resolver))) } Err(e) => { warn!("Error creating Hickory resolver, falling back to default: {e:?}"); Arc::new(Self::Default()) } } } // Note that we get an iterator of addresses, but we only grab the first one for convenience async fn resolve_domain(&self, name: &str) -> Result, BoxError> { pre_resolve(name)?; let result = match self { Self::Default() => tokio::net::lookup_host(name).await?.next(), Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), }; if let Some(addr) = &result { post_resolve(name, addr.ip())?; } Ok(result) } } fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { if should_block_address(name) { return Err(CustomHttpClientError::Blocked { domain: name.to_string(), }); } Ok(()) } fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { if should_block_ip(ip) { Err(CustomHttpClientError::NonGlobalIp { domain: Some(name.to_string()), ip, }) } else { Ok(()) } } impl Resolve for CustomDnsResolver { fn resolve(&self, name: Name) -> Resolving { let this = self.clone(); Box::pin(async move { let name = name.as_str(); let result = this.resolve_domain(name).await?; Ok::(Box::new(result.into_iter())) }) } } #[cfg(s3)] pub(crate) mod aws { use aws_smithy_runtime_api::client::{ http::{HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector}, orchestrator::HttpResponse, result::ConnectorError, runtime_components::RuntimeComponents, }; use reqwest::Client; // Adapter that wraps reqwest to be compatible with the AWS SDK #[derive(Debug)] pub(crate) struct AwsReqwestConnector { pub(crate) client: Client, } impl HttpConnector for AwsReqwestConnector { fn call(&self, request: aws_smithy_runtime_api::client::orchestrator::HttpRequest) -> HttpConnectorFuture { // Convert the AWS-style request to a reqwest request let client = self.client.clone(); let future = async move { let method = reqwest::Method::from_bytes(request.method().as_bytes()) .map_err(|e| ConnectorError::user(Box::new(e)))?; let mut req_builder = client.request(method, request.uri().to_string()); for (name, value) in request.headers() { req_builder = req_builder.header(name, value); } if let Some(body_bytes) = request.body().bytes() { req_builder = req_builder.body(body_bytes.to_vec()); } let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?; let status = response.status().into(); let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?; Ok(HttpResponse::new(status, bytes.into())) }; HttpConnectorFuture::new(Box::pin(future)) } } impl HttpClient for AwsReqwestConnector { fn http_connector( &self, _settings: &HttpConnectorSettings, _components: &RuntimeComponents, ) -> SharedHttpConnector { SharedHttpConnector::new(AwsReqwestConnector { client: self.client.clone(), }) } } } ================================================ FILE: src/mail.rs ================================================ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use std::{env::consts::EXE_SUFFIX, str::FromStr}; use lettre::{ message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, Address, AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Tokio1Executor, }; use crate::{ api::EmptyResult, auth::{ encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, generate_verify_email_claims, }, db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId}, error::Error, CONFIG, }; fn sendmail_transport() -> AsyncSendmailTransport { if let Some(command) = CONFIG.sendmail_command() { AsyncSendmailTransport::new_with_command(command) } else { AsyncSendmailTransport::new_with_command(format!("sendmail{EXE_SUFFIX}")) } } fn smtp_transport() -> AsyncSmtpTransport { use std::time::Duration; let host = CONFIG.smtp_host().unwrap(); let smtp_client = AsyncSmtpTransport::::builder_dangerous(host.as_str()) .port(CONFIG.smtp_port()) .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); // Determine security let smtp_client = if CONFIG.smtp_security() != *"off" { let mut tls_parameters = TlsParameters::builder(host); if CONFIG.smtp_accept_invalid_hostnames() { tls_parameters = tls_parameters.dangerous_accept_invalid_hostnames(true); } if CONFIG.smtp_accept_invalid_certs() { tls_parameters = tls_parameters.dangerous_accept_invalid_certs(true); } let tls_parameters = tls_parameters.build().unwrap(); if CONFIG.smtp_security() == *"force_tls" { smtp_client.tls(Tls::Wrapper(tls_parameters)) } else { smtp_client.tls(Tls::Required(tls_parameters)) } } else { smtp_client }; 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.helo_name() { Some(helo_name) => smtp_client.hello_name(ClientId::Domain(helo_name)), None => smtp_client, }; let smtp_client = match CONFIG.smtp_auth_mechanism() { Some(mechanism) => { let allowed_mechanisms = [SmtpAuthMechanism::Plain, SmtpAuthMechanism::Login, SmtpAuthMechanism::Xoauth2]; let mut selected_mechanisms = vec![]; for wanted_mechanism in mechanism.split(',') { for m in &allowed_mechanisms { if m.to_string().to_lowercase() == wanted_mechanism.trim_matches(|c| c == '"' || c == '\'' || c == ' ').to_lowercase() { selected_mechanisms.push(*m); } } } if !selected_mechanisms.is_empty() { smtp_client.authentication(selected_mechanisms) } else { // Only show a warning, and return without setting an actual authentication mechanism warn!("No valid SMTP Auth mechanism found for '{mechanism}', using default values"); smtp_client } } _ => smtp_client, }; smtp_client.build() } // This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections fn sanitize_data(data: &mut serde_json::Value) { use regex::Regex; use std::sync::LazyLock; static RE: LazyLock = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap()); match data { serde_json::Value::String(s) => *s = RE.replace_all(s, "").to_string(), serde_json::Value::Object(obj) => { for d in obj.values_mut() { sanitize_data(d); } } serde_json::Value::Array(arr) => { for d in arr.iter_mut() { sanitize_data(d); } } _ => {} } } fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String, String), Error> { let mut data = data; sanitize_data(&mut data); let (subject_html, body_html) = get_template(&format!("{template_name}.html"), &data)?; let (_subject_text, body_text) = get_template(template_name, &data)?; Ok((subject_html, body_html, body_text)) } fn get_template(template_name: &str, data: &serde_json::Value) -> Result<(String, String), Error> { let text = CONFIG.render_template(template_name, data)?; let mut text_split = text.split(""); let subject = match text_split.next() { Some(s) => s.trim().to_string(), None => err!("Template doesn't contain subject"), }; let body = match text_split.next() { Some(s) => s.trim().to_string(), None => err!("Template doesn't contain body"), }; if text_split.next().is_some() { err!("Template contains more than one body"); } Ok((subject, body)) } pub async fn send_password_hint(address: &str, hint: Option) -> EmptyResult { let template_name = if hint.is_some() { "email/pw_hint_some" } else { "email/pw_hint_none" }; let (subject, body_html, body_text) = get_text( template_name, json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "hint": hint, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { let claims = generate_delete_claims(user_id.to_string()); let delete_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( "email/delete_account", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_id": user_id, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": delete_token, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( "email/verify_email", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_id": user_id, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": verify_email_token, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult { let mut query = url::Url::parse("https://query.builder").unwrap(); query.query_pairs_mut().append_pair("email", email).append_pair("token", token); let query_string = match query.query() { None => err!("Failed to build verify URL query parameters"), Some(query) => query, }; let (subject, body_html, body_text) = get_text( "email/register_verify_email", json!({ // `url.Url` would place the anchor `#` after the query parameters "url": format!("{}/#/finish-signup/?{query_string}", CONFIG.domain()), "img_src": CONFIG._smtp_img_src(), "email": email, }), )?; send_email(email, &subject, body_html, body_text).await } pub async fn send_welcome(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/welcome", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { let claims = generate_verify_email_claims(user_id); let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( "email/welcome_must_verify", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_id": user_id, "token": verify_email_token, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_2fa_removed_from_org(address: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/send_2fa_removed_from_org", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/send_single_org_removed_from_org", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_invite( user: &User, org_id: OrganizationId, member_id: MembershipId, org_name: &str, invited_by_email: Option, ) -> EmptyResult { let claims = generate_invite_claims( user.uuid.clone(), user.email.clone(), org_id.clone(), member_id.clone(), invited_by_email, ); let invite_token = encode_jwt(&claims); let mut query = url::Url::parse("https://query.builder").unwrap(); { let mut query_params = query.query_pairs_mut(); query_params .append_pair("email", &user.email) .append_pair("organizationName", org_name) .append_pair("organizationId", &org_id) .append_pair("organizationUserId", &member_id) .append_pair("token", &invite_token); if CONFIG.sso_enabled() && CONFIG.sso_only() { query_params.append_pair("orgSsoIdentifier", &org_id); } if user.private_key.is_some() { query_params.append_pair("orgUserHasExistingUser", "true"); } } let Some(query_string) = query.query() else { err!("Failed to build invite URL query parameters") }; let (subject, body_html, body_text) = get_text( "email/send_org_invite", json!({ // `url.Url` would place the anchor `#` after the query parameters "url": format!("{}/#/accept-organization/?{query_string}", CONFIG.domain()), "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; send_email(&user.email, &subject, body_html, body_text).await } pub async fn send_emergency_access_invite( address: &str, user_id: UserId, emer_id: EmergencyAccessId, grantor_name: &str, grantor_email: &str, ) -> EmptyResult { let claims = generate_emergency_access_invite_claims( user_id, String::from(address), emer_id.clone(), String::from(grantor_name), String::from(grantor_email), ); // Build the query here to ensure proper escaping let mut query = url::Url::parse("https://query.builder").unwrap(); { let mut query_params = query.query_pairs_mut(); query_params .append_pair("id", &emer_id.to_string()) .append_pair("name", grantor_name) .append_pair("email", address) .append_pair("token", &encode_jwt(&claims)); } let Some(query_string) = query.query() else { err!("Failed to build emergency invite URL query parameters") }; let (subject, body_html, body_text) = get_text( "email/send_emergency_access_invite", json!({ // `url.Url` would place the anchor `#` after the query parameters "url": format!("{}/#/accept-emergency/?{query_string}", CONFIG.domain()), "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_invite_accepted", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantee_email": grantee_email, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_invite_confirmed", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_recovery_approved(address: &str, grantor_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_recovery_approved", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_recovery_initiated( address: &str, grantee_name: &str, atype: &str, wait_time_days: &i32, ) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_recovery_initiated", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "wait_time_days": wait_time_days, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_recovery_reminder( address: &str, grantee_name: &str, atype: &str, days_left: &str, ) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_recovery_reminder", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "days_left": days_left, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_recovery_rejected(address: &str, grantor_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_recovery_rejected", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_emergency_access_recovery_timed_out(address: &str, grantee_name: &str, atype: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/emergency_access_recovery_timed_out", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/invite_accepted", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "email": new_user_email, "org_name": org_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/invite_confirmed", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTime, device: &Device) -> EmptyResult { use crate::util::upcase_first; let fmt = "%A, %B %_d, %Y at %r %Z"; let (subject, body_html, body_text) = get_text( "email/new_device_logged_in", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "ip": ip, "device_name": upcase_first(&device.name), "device_type": DeviceType::from_i32(device.atype).to_string(), "datetime": crate::util::format_naive_datetime_local(dt, fmt), }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_incomplete_2fa_login( address: &str, ip: &str, dt: &NaiveDateTime, device_name: &str, device_type: &str, ) -> EmptyResult { use crate::util::upcase_first; let fmt = "%A, %B %_d, %Y at %r %Z"; let (subject, body_html, body_text) = get_text( "email/incomplete_2fa_login", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "ip": ip, "device_name": upcase_first(device_name), "device_type": device_type, "datetime": crate::util::format_naive_datetime_local(dt, fmt), "time_limit": CONFIG.incomplete_2fa_time_limit(), }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_token(address: &str, token: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/twofactor_email", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/change_email", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_change_email_existing(address: &str, acting_address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/change_email_existing", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "existing_address": address, "acting_address": acting_address, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_change_email_invited(address: &str, acting_address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/change_email_invited", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "existing_address": address, "acting_address": acting_address, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_sso_change_email(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/sso_change_email", json!({ "url": format!("{}/#/settings/account", CONFIG.domain()), "img_src": CONFIG._smtp_img_src(), }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/admin_reset_password", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_name": user_name, "org_name": org_name, }), )?; send_email(address, &subject, body_html, body_text).await } pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/protected_action", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; send_email(address, &subject, body_html, body_text).await } async fn send_with_selected_transport(email: Message) -> EmptyResult { if CONFIG.use_sendmail() { match sendmail_transport().send(email).await { Ok(_) => Ok(()), // Match some common errors and make them more user friendly Err(e) => { if e.is_client() { debug!("Sendmail client error: {e:?}"); err!(format!("Sendmail client error: {e}")); } else if e.is_response() { debug!("Sendmail response error: {e:?}"); err!(format!("Sendmail response error: {e}")); } else { debug!("Sendmail error: {e:?}"); err!(format!("Sendmail error: {e}")); } } } } else { match smtp_transport().send(email).await { Ok(_) => Ok(()), // Match some common errors and make them more user friendly Err(e) => { if e.is_client() { debug!("SMTP client error: {e:#?}"); err!(format!("SMTP client error: {e}")); } else if e.is_transient() { debug!("SMTP 4xx error: {e:#?}"); err!(format!("SMTP 4xx error: {e}")); } else if e.is_permanent() { debug!("SMTP 5xx error: {e:#?}"); let mut msg = e.to_string(); // Add a special check for 535 to add a more descriptive message if msg.contains("(535)") { msg = format!("{msg} - Authentication credentials invalid"); } err!(format!("SMTP 5xx error: {msg}")); } else if e.is_timeout() { debug!("SMTP timeout error: {e:#?}"); err!(format!("SMTP timeout error: {e}")); } else if e.is_tls() { debug!("SMTP encryption error: {e:#?}"); err!(format!("SMTP encryption error: {e}")); } else { debug!("SMTP error: {e:#?}"); err!(format!("SMTP error: {e}")); } } } } } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { let smtp_from = Address::from_str(&CONFIG.smtp_from())?; let body = if CONFIG.smtp_embed_images() { let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) .singlepart( Attachment::new_inline(String::from("logo-gray.png")) .body(logo_gray_body, "image/png".parse().unwrap()), ) .singlepart( Attachment::new_inline(String::from("mail-github.png")) .body(mail_github_body, "image/png".parse().unwrap()), ), ) } else { MultiPart::alternative_plain_html(body_text, body_html) }; let email = Message::builder() .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.domain()))) .to(Mailbox::new(None, Address::from_str(address)?)) .from(Mailbox::new(Some(CONFIG.smtp_from_name()), smtp_from)) .subject(subject) .multipart(body)?; send_with_selected_transport(email).await } ================================================ FILE: src/main.rs ================================================ #![cfg_attr(feature = "unstable", feature(ip))] // The recursion_limit is mainly triggered by the json!() macro. // The more key/value pairs there are the more recursion occurs. // We want to keep this as low as possible! #![recursion_limit = "165"] // When enabled use MiMalloc as malloc instead of the default malloc #[cfg(feature = "enable_mimalloc")] use mimalloc::MiMalloc; #[cfg(feature = "enable_mimalloc")] #[cfg_attr(feature = "enable_mimalloc", global_allocator)] static GLOBAL: MiMalloc = MiMalloc; #[macro_use] extern crate rocket; #[macro_use] extern crate serde; #[macro_use] extern crate serde_json; #[macro_use] extern crate log; #[macro_use] extern crate diesel; #[macro_use] extern crate diesel_migrations; #[macro_use] extern crate diesel_derive_newtype; use std::{ collections::HashMap, fs::{canonicalize, create_dir_all}, panic, path::Path, process::exit, str::FromStr, thread, }; use tokio::{ fs::File, io::{AsyncBufReadExt, BufReader}, }; #[cfg(unix)] use tokio::signal::unix::SignalKind; #[macro_use] mod error; mod api; mod auth; mod config; mod crypto; #[macro_use] mod db; mod http_client; mod mail; mod ratelimit; mod sso; mod sso_client; mod util; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::purge_auth_requests; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; pub use config::{PathType, CONFIG}; pub use error::{Error, MapResult}; use rocket::data::{Limits, ToByteUnit}; use std::sync::{atomic::Ordering, Arc}; pub use util::is_running_in_container; #[rocket::main] async fn main() -> Result<(), Error> { parse_args(); launch_info(); let level = init_logging()?; check_data_folder().await; auth::initialize_keys().await.unwrap_or_else(|e| { error!("Error creating private key '{}'\n{e:?}\nExiting Vaultwarden!", CONFIG.private_rsa_key()); exit(1); }); check_web_vault(); create_dir(&CONFIG.tmp_folder(), "tmp folder"); let pool = create_db_pool().await; schedule_jobs(pool.clone()); db::models::TwoFactor::migrate_u2f_to_webauthn(&pool.get().await.unwrap()).await.unwrap(); db::models::TwoFactor::migrate_credential_to_passkey(&pool.get().await.unwrap()).await.unwrap(); let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug); launch_rocket(pool, extra_debug).await // Blocks until program termination. } const HELP: &str = "\ Alternative implementation of the Bitwarden server API written in Rust USAGE: vaultwarden [FLAGS|COMMAND] FLAGS: -h, --help Prints help information -v, --version Prints the app and web-vault version COMMAND: hash [--preset {bitwarden|owasp}] Generate an Argon2id PHC ADMIN_TOKEN backup Create a backup of the SQLite database You can also send the USR1 signal to trigger a backup PRESETS: m= t= p= bitwarden (default) 64MiB, 3 Iterations, 4 Threads owasp 19MiB, 2 Iterations, 1 Thread "; pub const VERSION: Option<&str> = option_env!("VW_VERSION"); fn parse_args() { let mut pargs = pico_args::Arguments::from_env(); let version = VERSION.unwrap_or("(Version info from Git not present)"); if pargs.contains(["-h", "--help"]) { println!("Vaultwarden {version}"); print!("{HELP}"); exit(0); } else if pargs.contains(["-v", "--version"]) { config::SKIP_CONFIG_VALIDATION.store(true, Ordering::Relaxed); let web_vault_version = util::get_active_web_release(); println!("Vaultwarden {version}"); println!("Web-Vault {web_vault_version}"); exit(0); } if let Some(command) = pargs.subcommand().unwrap_or_default() { if command == "hash" { use argon2::{ password_hash::SaltString, Algorithm::Argon2id, Argon2, ParamsBuilder, PasswordHasher, Version::V0x13, }; let mut argon2_params = ParamsBuilder::new(); let preset: Option = pargs.opt_value_from_str(["-p", "--preset"]).unwrap_or_default(); let selected_preset; match preset.as_deref() { Some("owasp") => { selected_preset = "owasp"; argon2_params.m_cost(19456); argon2_params.t_cost(2); argon2_params.p_cost(1); } _ => { // Bitwarden preset is the default selected_preset = "bitwarden"; argon2_params.m_cost(65540); argon2_params.t_cost(3); argon2_params.p_cost(4); } } println!("Generate an Argon2id PHC string using the '{selected_preset}' preset:\n"); let password = rpassword::prompt_password("Password: ").unwrap(); if password.len() < 8 { println!("\nPassword must contain at least 8 characters"); exit(1); } let password_verify = rpassword::prompt_password("Confirm Password: ").unwrap(); if password != password_verify { println!("\nPasswords do not match"); exit(1); } let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap()); let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap(); let argon2_timer = tokio::time::Instant::now(); if let Ok(password_hash) = argon2.hash_password(password.as_bytes(), &salt) { println!( "\n\ ADMIN_TOKEN='{password_hash}'\n\n\ Generation of the Argon2id PHC string took: {:?}", argon2_timer.elapsed() ); } else { println!("Unable to generate Argon2id PHC hash."); exit(1); } } else if command == "backup" { match db::backup_sqlite() { Ok(f) => { println!("Backup to '{f}' was successful"); exit(0); } Err(e) => { println!("Backup failed. {e:?}"); exit(1); } } } exit(0); } } fn launch_info() { println!( "\ /--------------------------------------------------------------------\\\n\ | Starting Vaultwarden |" ); if let Some(version) = VERSION { println!("|{:^68}|", format!("Version {version}")); } println!( "\ |--------------------------------------------------------------------|\n\ | This is an *unofficial* Bitwarden implementation, DO NOT use the |\n\ | official channels to report bugs/features, regardless of client. |\n\ | Send usage/configuration questions or feature requests to: |\n\ | https://github.com/dani-garcia/vaultwarden/discussions or |\n\ | https://vaultwarden.discourse.group/ |\n\ | Report suspected bugs/issues in the software itself at: |\n\ | https://github.com/dani-garcia/vaultwarden/issues/new |\n\ \\--------------------------------------------------------------------/\n" ); } fn init_logging() -> Result { let levels = log::LevelFilter::iter().map(|lvl| lvl.as_str().to_lowercase()).collect::>().join("|"); let log_level_rgx_str = format!("^({levels})((,[^,=]+=({levels}))*)$"); let log_level_rgx = regex::Regex::new(&log_level_rgx_str)?; let config_str = CONFIG.log_level().to_lowercase(); let (level, levels_override) = if let Some(caps) = log_level_rgx.captures(&config_str) { let level = caps .get(1) .and_then(|m| log::LevelFilter::from_str(m.as_str()).ok()) .ok_or(Error::new("Failed to parse global log level".to_string(), ""))?; let levels_override: Vec<(&str, log::LevelFilter)> = caps .get(2) .map(|m| { m.as_str() .split(',') .collect::>() .into_iter() .flat_map(|s| match s.split_once('=') { Some((log, lvl_str)) => log::LevelFilter::from_str(lvl_str).ok().map(|lvl| (log, lvl)), _ => None, }) .collect() }) .ok_or(Error::new("Failed to parse overrides".to_string(), ""))?; (level, levels_override) } else { err!(format!("LOG_LEVEL should follow the format info,vaultwarden::api::icons=debug, invalid: {config_str}")) }; // Depending on the main log level we either want to disable or enable logging for hickory. // Else if there are timeouts it will clutter the logs since hickory uses warn for this. let hickory_level = if level >= log::LevelFilter::Debug { level } else { log::LevelFilter::Off }; // Only show Rocket underscore `_` logs when the level is Debug or higher // Else this will bloat the log output with useless messages. let rocket_underscore_level = if level >= log::LevelFilter::Debug { log::LevelFilter::Warn } else { log::LevelFilter::Off }; // Only show handlebar logs when the level is Trace let handlebars_level = if level >= log::LevelFilter::Trace { log::LevelFilter::Trace } else { log::LevelFilter::Warn }; // Enable smtp debug logging only specifically for smtp when need. // This can contain sensitive information we do not want in the default debug/trace logging. let smtp_log_level = if CONFIG.smtp_debug() { log::LevelFilter::Debug } else { log::LevelFilter::Off }; let mut default_levels = HashMap::from([ // Hide unknown certificate errors if using self-signed ("rustls::session", log::LevelFilter::Off), // Hide failed to close stream messages ("hyper::server", log::LevelFilter::Warn), // Silence Rocket `_` logs ("_", rocket_underscore_level), ("rocket::response::responder::_", rocket_underscore_level), ("rocket::server::_", rocket_underscore_level), ("vaultwarden::api::admin::_", rocket_underscore_level), ("vaultwarden::api::notifications::_", rocket_underscore_level), // Silence Rocket logs ("rocket::launch", log::LevelFilter::Error), ("rocket::launch_", log::LevelFilter::Error), ("rocket::rocket", log::LevelFilter::Warn), ("rocket::server", log::LevelFilter::Warn), ("rocket::fairing::fairings", log::LevelFilter::Warn), ("rocket::shield::shield", log::LevelFilter::Warn), ("hyper::proto", log::LevelFilter::Off), ("hyper::client", log::LevelFilter::Off), // Filter handlebars logs ("handlebars::render", handlebars_level), // Prevent cookie_store logs ("cookie_store", log::LevelFilter::Off), // Variable level for hickory used by reqwest ("hickory_resolver::name_server::name_server", hickory_level), ("hickory_proto::xfer", hickory_level), // SMTP ("lettre::transport::smtp", smtp_log_level), // Set query_logger default to Off, but can be overwritten manually // You can set LOG_LEVEL=info,vaultwarden::db::query_logger= to overwrite it. // This makes it possible to do the following: // warn = Print slow queries only, 5 seconds or longer // info = Print slow queries only, 1 second or longer // debug = Print all queries ("vaultwarden::db::query_logger", log::LevelFilter::Off), ]); for (path, level) in levels_override.into_iter() { let _ = default_levels.insert(path, level); } if Some(&log::LevelFilter::Debug) == default_levels.get("lettre::transport::smtp") { println!( "[WARNING] SMTP Debugging is enabled (SMTP_DEBUG=true). Sensitive information could be disclosed via logs!\n\ [WARNING] Only enable SMTP_DEBUG during troubleshooting!\n" ); } let mut logger = fern::Dispatch::new().level(level).chain(std::io::stdout()); for (path, level) in default_levels { logger = logger.level_for(path.to_string(), level); } if CONFIG.extended_logging() { logger = logger.format(|out, message, record| { out.finish(format_args!( "[{}][{}][{}] {}", chrono::Local::now().format(&CONFIG.log_timestamp_format()), record.target(), record.level(), message )) }); } else { logger = logger.format(|out, message, _| out.finish(format_args!("{message}"))); } if let Some(log_file) = CONFIG.log_file() { #[cfg(windows)] { logger = logger.chain(fern::log_file(log_file)?); } #[cfg(unix)] { const SIGHUP: i32 = SignalKind::hangup().as_raw_value(); let path = Path::new(&log_file); logger = logger.chain(fern::log_reopen1(path, [SIGHUP])?); } } #[cfg(unix)] { if cfg!(feature = "enable_syslog") || CONFIG.use_syslog() { logger = chain_syslog(logger); } } if let Err(err) = logger.apply() { err!(format!("Failed to activate logger: {err}")) } // Catch panics and log them instead of default output to StdErr panic::set_hook(Box::new(|info| { 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::() { Some(s) => &**s, None => "Box", }, }; let backtrace = std::backtrace::Backtrace::force_capture(); match info.location() { Some(location) => { error!( target: "panic", "thread '{}' panicked at '{}': {}:{}\n{:}", thread, msg, location.file(), location.line(), backtrace ); } None => error!( target: "panic", "thread '{thread}' panicked at '{msg}'\n{backtrace:}" ), } })); Ok(level) } #[cfg(unix)] fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch { let syslog_fmt = syslog::Formatter3164 { facility: syslog::Facility::LOG_USER, hostname: None, process: "vaultwarden".into(), pid: 0, }; match syslog::unix(syslog_fmt) { Ok(sl) => logger.chain(sl), Err(e) => { error!("Unable to connect to syslog: {e:?}"); logger } } } fn create_dir(path: &str, description: &str) { // Try to create the specified dir, if it doesn't already exist. let err_msg = format!("Error creating {description} directory '{path}'"); create_dir_all(path).expect(&err_msg); } async fn check_data_folder() { let data_folder = &CONFIG.data_folder(); if data_folder.starts_with("s3://") { if let Err(e) = CONFIG .opendal_operator_for_path_type(&PathType::Data) .unwrap_or_else(|e| { error!("Failed to create S3 operator for data folder '{data_folder}': {e:?}"); exit(1); }) .check() .await { error!("Could not access S3 data folder '{data_folder}': {e:?}"); exit(1); } return; } let path = Path::new(data_folder); if !path.exists() { error!("Data folder '{data_folder}' doesn't exist."); if is_running_in_container() { error!("Verify that your data volume is mounted at the correct location."); } else { error!("Create the data folder and try again."); } exit(1); } if !path.is_dir() { error!("Data folder '{data_folder}' is not a directory."); exit(1); } if is_running_in_container() && std::env::var("I_REALLY_WANT_VOLATILE_STORAGE").is_err() && !container_data_folder_is_persistent(data_folder).await { error!( "No persistent volume!\n\ ########################################################################################\n\ # It looks like you did not configure a persistent volume! #\n\ # This will result in permanent data loss when the container is removed or updated! #\n\ # If you really want to use volatile storage set `I_REALLY_WANT_VOLATILE_STORAGE=true` #\n\ ########################################################################################\n" ); exit(1); } } /// Detect when using Docker or Podman the DATA_FOLDER is either a bind-mount or a volume created manually. /// If not created manually, then the data will not be persistent. /// A none persistent volume in either Docker or Podman is represented by a 64 alphanumerical string. /// If we detect this string, we will alert about not having a persistent self defined volume. /// This probably means that someone forgot to add `-v /path/to/vaultwarden_data/:/data` async fn container_data_folder_is_persistent(data_folder: &str) -> bool { if let Ok(mountinfo) = File::open("/proc/self/mountinfo").await { // Since there can only be one mountpoint to the DATA_FOLDER // We do a basic check for this mountpoint surrounded by a space. let data_folder_match = if data_folder.starts_with('/') { format!(" {data_folder} ") } else { format!(" /{data_folder} ") }; let mut lines = BufReader::new(mountinfo).lines(); let re = regex::Regex::new(r"/volumes/[a-z0-9]{64}/_data /").unwrap(); while let Some(line) = lines.next_line().await.unwrap_or_default() { // Only execute a regex check if we find the base match if line.contains(&data_folder_match) { if re.is_match(&line) { return false; } // If we did found a match for the mountpoint, but not the regex, then still stop searching. break; } } } // In all other cases, just assume a true. // This is just an informative check to try and prevent data loss. true } fn check_web_vault() { if !CONFIG.web_vault_enabled() { return; } let index_path = Path::new(&CONFIG.web_vault_folder()).join("index.html"); if !index_path.exists() { error!( "Web vault is not found at '{}'. To install it, please follow the steps in: ", CONFIG.web_vault_folder() ); error!("https://github.com/dani-garcia/vaultwarden/wiki/Building-binary#install-the-web-vault"); error!("You can also set the environment variable 'WEB_VAULT_ENABLED=false' to disable it"); exit(1); } } async fn create_db_pool() -> db::DbPool { match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries()).await { Ok(p) => p, Err(e) => { error!("Error creating database pool: {e:?}"); exit(1); } } } async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> { let basepath = &CONFIG.domain_path(); let mut config = rocket::Config::from(rocket::Config::figment()); config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into(); config.cli_colors = false; // Make sure Rocket does not color any values for logging. config.limits = Limits::new() .limit("json", 20.megabytes()) // 20MB should be enough for very large imports, something like 5000+ vault entries .limit("data-form", 525.megabytes()) // This needs to match the maximum allowed file size for Send .limit("file", 525.megabytes()); // This needs to match the maximum allowed file size for attachments // If adding more paths here, consider also adding them to // crate::utils::LOGGED_ROUTES to make sure they appear in the log let instance = rocket::custom(config) .mount([basepath, "/"].concat(), api::web_routes()) .mount([basepath, "/api"].concat(), api::core_routes()) .mount([basepath, "/admin"].concat(), api::admin_routes()) .mount([basepath, "/events"].concat(), api::core_events_routes()) .mount([basepath, "/identity"].concat(), api::identity_routes()) .mount([basepath, "/icons"].concat(), api::icons_routes()) .mount([basepath, "/notifications"].concat(), api::notifications_routes()) .register([basepath, "/"].concat(), api::web_catchers()) .register([basepath, "/api"].concat(), api::core_catchers()) .register([basepath, "/admin"].concat(), api::admin_catchers()) .manage(pool) .manage(Arc::clone(&WS_USERS)) .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .attach(util::AppHeaders()) .attach(util::Cors()) .attach(util::BetterLogging(extra_debug)) .ignite() .await?; CONFIG.set_rocket_shutdown_handle(instance.shutdown()); tokio::spawn(async move { tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler"); info!("Exiting Vaultwarden!"); CONFIG.shutdown(); }); #[cfg(all(unix, sqlite))] { if db::ACTIVE_DB_TYPE.get() != Some(&db::DbConnType::Sqlite) { debug!("PostgreSQL and MySQL/MariaDB do not support this backup feature, skip adding USR1 signal."); } else { tokio::spawn(async move { let mut signal_user1 = tokio::signal::unix::signal(SignalKind::user_defined1()).unwrap(); loop { // If we need more signals to act upon, we might want to use select! here. // With only one item to listen for this is enough. let _ = signal_user1.recv().await; match db::backup_sqlite() { Ok(f) => info!("Backup to '{f}' was successful"), Err(e) => error!("Backup failed. {e:?}"), } } }); } } instance.launch().await?; info!("Vaultwarden process exited!"); Ok(()) } fn schedule_jobs(pool: db::DbPool) { if CONFIG.job_poll_interval_ms() == 0 { info!("Job scheduler disabled."); return; } let runtime = tokio::runtime::Runtime::new().unwrap(); thread::Builder::new() .name("job-scheduler".to_string()) .spawn(move || { use job_scheduler_ng::{Job, JobScheduler}; let _runtime_guard = runtime.enter(); let mut sched = JobScheduler::new(); // Purge sends that are past their deletion date. if !CONFIG.send_purge_schedule().is_empty() { sched.add(Job::new(CONFIG.send_purge_schedule().parse().unwrap(), || { runtime.spawn(api::purge_sends(pool.clone())); })); } // Purge trashed items that are old enough to be auto-deleted. if !CONFIG.trash_purge_schedule().is_empty() { sched.add(Job::new(CONFIG.trash_purge_schedule().parse().unwrap(), || { runtime.spawn(api::purge_trashed_ciphers(pool.clone())); })); } // Send email notifications about incomplete 2FA logins, which potentially // indicates that a user's master password has been compromised. if !CONFIG.incomplete_2fa_schedule().is_empty() { sched.add(Job::new(CONFIG.incomplete_2fa_schedule().parse().unwrap(), || { runtime.spawn(api::send_incomplete_2fa_notifications(pool.clone())); })); } // Grant emergency access requests that have met the required wait time. // This job should run before the emergency access reminders job to avoid // sending reminders for requests that are about to be granted anyway. if !CONFIG.emergency_request_timeout_schedule().is_empty() { sched.add(Job::new(CONFIG.emergency_request_timeout_schedule().parse().unwrap(), || { runtime.spawn(api::emergency_request_timeout_job(pool.clone())); })); } // Send reminders to emergency access grantors that there are pending // emergency access requests. if !CONFIG.emergency_notification_reminder_schedule().is_empty() { sched.add(Job::new(CONFIG.emergency_notification_reminder_schedule().parse().unwrap(), || { runtime.spawn(api::emergency_notification_reminder_job(pool.clone())); })); } if !CONFIG.auth_request_purge_schedule().is_empty() { sched.add(Job::new(CONFIG.auth_request_purge_schedule().parse().unwrap(), || { runtime.spawn(purge_auth_requests(pool.clone())); })); } // Clean unused, expired Duo authentication contexts. if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() { sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || { runtime.spawn(purge_duo_contexts(pool.clone())); })); } // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty() && CONFIG.events_days_retain().is_some() { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { runtime.spawn(api::event_cleanup_job(pool.clone())); })); } // Purge sso auth from incomplete flow (default to daily at 00h20). if !CONFIG.purge_incomplete_sso_auth().is_empty() { sched.add(Job::new(CONFIG.purge_incomplete_sso_auth().parse().unwrap(), || { runtime.spawn(db::models::SsoAuth::delete_expired(pool.clone())); })); } // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to // schedule jobs to run more frequently for some reason can reduce // the poll interval accordingly. // // Note that the scheduler checks jobs in the order in which they // were added, so if two jobs are both eligible to run at a given // tick, the one that was added earlier will run first. loop { sched.tick(); runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); } }) .expect("Error spawning job scheduler thread"); } ================================================ FILE: src/ratelimit.rs ================================================ use std::{net::IpAddr, num::NonZeroU32, sync::LazyLock, time::Duration}; use governor::{clock::DefaultClock, state::keyed::DashMapStateStore, Quota, RateLimiter}; use crate::{Error, CONFIG}; type Limiter = RateLimiter, DefaultClock>; static LIMITER_LOGIN: LazyLock = LazyLock::new(|| { let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) }); static LIMITER_ADMIN: LazyLock = LazyLock::new(|| { let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) }); pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { match LIMITER_LOGIN.check_key(ip) { Ok(_) => Ok(()), Err(_e) => { err_code!("Too many login requests", 429); } } } pub fn check_limit_admin(ip: &IpAddr) -> Result<(), Error> { match LIMITER_ADMIN.check_key(ip) { Ok(_) => Ok(()), Err(_e) => { err_code!("Too many admin requests", 429); } } } ================================================ FILE: src/sso.rs ================================================ use std::{sync::LazyLock, time::Duration}; use chrono::Utc; use derive_more::{AsRef, Deref, Display, From, Into}; use regex::Regex; use url::Url; use crate::{ api::ApiResult, auth, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User}, DbConn, }, sso_client::Client, CONFIG, }; pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC"; static SSO_JWT_ISSUER: LazyLock = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin())); pub static SSO_AUTH_EXPIRATION: LazyLock = LazyLock::new(|| chrono::TimeDelta::try_minutes(10).unwrap()); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCCode(String); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, Into, )] #[deref(forward)] #[into(owned)] pub struct OIDCCodeChallenge(String); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, Into, )] #[deref(forward)] #[into(owned)] pub struct OIDCCodeVerifier(String); #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCState(String); #[derive(Debug, Serialize, Deserialize)] struct SsoTokenJwtClaims { // Not before pub nbf: i64, // Expiration time pub exp: i64, // Issuer pub iss: String, // Subject pub sub: String, } pub fn encode_ssotoken_claims() -> String { let time_now = Utc::now(); let claims = SsoTokenJwtClaims { nbf: time_now.timestamp(), exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(), iss: SSO_JWT_ISSUER.to_string(), sub: "vaultwarden".to_string(), }; auth::encode_jwt(&claims) } #[derive(Clone, Debug, Serialize, Deserialize)] struct BasicTokenClaims { iat: Option, nbf: Option, exp: i64, } #[derive(Deserialize)] struct BasicTokenClaimsValidation { exp: u64, iss: String, } impl BasicTokenClaims { fn nbf(&self) -> i64 { self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp()) } } fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { // We need to manually validate this token, since `insecure_decode` does not do this match jsonwebtoken::dangerous::insecure_decode::(token) { Ok(btcv) => { let now = jsonwebtoken::get_current_timestamp(); let validate_claim = btcv.claims; // Validate the exp in the claim with a leeway of 60 seconds, same as jsonwebtoken does if validate_claim.exp < now - 60 { err_silent!(format!("Expired Signature for base token claim from {token_name}")) } if validate_claim.iss.ne(&CONFIG.sso_authority()) { err_silent!(format!("Invalid Issuer for base token claim from {token_name}")) } // All is validated and ok, lets decode again using the wanted struct let btc = jsonwebtoken::dangerous::insecure_decode::(token).unwrap(); Ok(btc.claims) } Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), } } pub fn decode_state(base64_state: &str) -> ApiResult { let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) { Ok(vec) => match String::from_utf8(vec) { Ok(valid) => OIDCState(valid), Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")), }, Err(_) => err!(format!("Failed to decode {base64_state} using base64")), }; Ok(state) } // redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs pub async fn authorize_url( state: OIDCState, client_challenge: OIDCCodeChallenge, client_id: &str, raw_redirect_uri: &str, conn: DbConn, ) -> ApiResult { let redirect_uri = match client_id { "web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()), "desktop" | "mobile" => "bitwarden://sso-callback".to_string(), "cli" => { let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap(); match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) { Some(port) => format!("http://localhost:{port}"), None => err!("Failed to extract port number"), } } _ => err!(format!("Unsupported client {client_id}")), }; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri).await?; sso_auth.save(&conn).await?; Ok(auth_url) } #[derive( Clone, Debug, Default, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize, AsRef, Deref, Display, From, )] #[deref(forward)] #[from(forward)] pub struct OIDCIdentifier(String); impl OIDCIdentifier { fn new(issuer: &str, subject: &str) -> Self { OIDCIdentifier(format!("{issuer}/{subject}")) } } // During the 2FA flow we will // - retrieve the user information and then only discover he needs 2FA. // - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged. // The `SsoAuth` will ensure that the user is authorized only once. pub async fn exchange_code( state: &OIDCState, client_verifier: OIDCCodeVerifier, conn: &DbConn, ) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> { use openidconnect::OAuth2TokenResponse; let mut sso_auth = match SsoAuth::find(state, conn).await { None => err!(format!("Invalid state cannot retrieve sso auth")), Some(sso_auth) => sso_auth, }; if let Some(authenticated_user) = sso_auth.auth_response.clone() { return Ok((sso_auth, authenticated_user)); } let code = match sso_auth.code_response.clone() { Some(OIDCCodeWrapper::Ok { code, }) => code.clone(), Some(OIDCCodeWrapper::Error { error, error_description, }) => { sso_auth.delete(conn).await?; err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new()))) } None => { sso_auth.delete(conn).await?; err!("Missing authorization provider return"); } }; let client = Client::cached().await?; let (token_response, id_claims) = client.exchange_code(code, client_verifier, &sso_auth).await?; let user_info = client.user_info(token_response.access_token().to_owned()).await?; let email = match id_claims.email().or(user_info.email()) { None => err!("Neither id token nor userinfo contained an email"), Some(e) => e.to_string().to_lowercase(), }; let email_verified = id_claims.email_verified().or(user_info.email_verified()); let user_name = id_claims.preferred_username().map(|un| un.to_string()); let refresh_token = token_response.refresh_token().map(|t| t.secret()); if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { error!("Scope offline_access is present but response contain no refresh_token"); } let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject()); let authenticated_user = OIDCAuthenticatedUser { refresh_token: refresh_token.cloned(), access_token: token_response.access_token().secret().clone(), expires_in: token_response.expires_in(), identifier: identifier.clone(), email: email.clone(), email_verified, user_name: user_name.clone(), }; debug!("Authenticated user {authenticated_user:?}"); sso_auth.auth_response = Some(authenticated_user.clone()); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; Ok((sso_auth, authenticated_user)) } // User has passed 2FA flow we can delete auth info from database pub async fn redeem( device: &Device, user: &User, client_id: Option, sso_user: Option, sso_auth: SsoAuth, auth_user: OIDCAuthenticatedUser, conn: &DbConn, ) -> ApiResult { sso_auth.delete(conn).await?; if sso_user.is_none() { let user_sso = SsoUser { user_uuid: user.uuid.clone(), identifier: auth_user.identifier.clone(), }; user_sso.save(conn).await?; } if !CONFIG.sso_auth_only_not_session() { let now = Utc::now(); let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &auth_user.access_token), auth_user.expires_in) { (Ok(ap), _) => (ap.nbf(), ap.exp), (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), _ => err!("Non jwt access_token and empty expires_in"), }; let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); _create_auth_tokens(device, auth_user.refresh_token, access_claims, auth_user.access_token) } else { Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } } // We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). // If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity pub fn create_auth_tokens( device: &Device, user: &User, client_id: Option, refresh_token: Option, access_token: String, expires_in: Option, ) -> ApiResult { if !CONFIG.sso_auth_only_not_session() { let now = Utc::now(); let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) { (Ok(ap), _) => (ap.nbf(), ap.exp), (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), _ => err!("Non jwt access_token and empty expires_in"), }; let access_claims = auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now); _create_auth_tokens(device, refresh_token, access_claims, access_token) } else { Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id)) } } fn _create_auth_tokens( device: &Device, refresh_token: Option, access_claims: auth::LoginJwtClaims, access_token: String, ) -> ApiResult { let (nbf, exp, token) = if let Some(rt) = refresh_token { match decode_token_claims("refresh_token", &rt) { Err(_) => { let time_now = Utc::now(); let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); debug!("Non jwt refresh_token (expiration set to {exp})"); (time_now.timestamp(), exp, TokenWrapper::Refresh(rt)) } Ok(refresh_payload) => { debug!("Refresh_payload: {refresh_payload:?}"); (refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt)) } } } else { debug!("No refresh_token present"); (access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token)) }; let refresh_claims = auth::RefreshJwtClaims { nbf, exp, iss: auth::JWT_LOGIN_ISSUER.to_string(), sub: AuthMethod::Sso, device_token: device.refresh_token.clone(), token: Some(token), }; Ok(AuthTokens { refresh_claims, access_claims, }) } // This endpoint is called in two case // - the session is close to expiration we will try to extend it // - the user is going to make an action and we check that the session is still valid pub async fn exchange_refresh_token( device: &Device, user: &User, client_id: Option, refresh_claims: auth::RefreshJwtClaims, ) -> ApiResult { let exp = refresh_claims.exp; match refresh_claims.token { Some(TokenWrapper::Refresh(refresh_token)) => { // Use new refresh_token if returned let (new_refresh_token, access_token, expires_in) = Client::exchange_refresh_token(refresh_token.clone()).await?; create_auth_tokens( device, user, client_id, new_refresh_token.or(Some(refresh_token)), access_token, expires_in, ) } Some(TokenWrapper::Access(access_token)) => { let now = Utc::now(); let exp_limit = (now + *BW_EXPIRATION).timestamp(); if exp < exp_limit { err_silent!("Access token is close to expiration but we have no refresh token") } Client::check_validity(access_token.clone()).await?; let access_claims = auth::LoginJwtClaims::new( device, user, now.timestamp(), exp, AuthMethod::Sso.scope_vec(), client_id, now, ); _create_auth_tokens(device, None, access_claims, access_token) } None => err!("No token present while in SSO"), } } ================================================ FILE: src/sso_client.rs ================================================ use std::{borrow::Cow, sync::LazyLock, time::Duration}; use openidconnect::{core::*, reqwest, *}; use regex::Regex; use url::Url; use crate::{ api::{ApiResult, EmptyResult}, db::models::SsoAuth, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, CONFIG, }; static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_string()); static CLIENT_CACHE: LazyLock> = LazyLock::new(|| { moka::sync::Cache::builder() .max_capacity(1) .time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) .build() }); static REFRESH_CACHE: LazyLock>> = LazyLock::new(|| moka::future::Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(30)).build()); /// OpenID Connect Core client. pub type CustomClient = openidconnect::Client< EmptyAdditionalClaims, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, CoreTokenResponse, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet, EndpointSet, >; pub type RefreshTokenResponse = (Option, String, Option); #[derive(Clone)] pub struct Client { pub http_client: reqwest::Client, pub core_client: CustomClient, } impl Client { // Call the OpenId discovery endpoint to retrieve configuration async fn _get_client() -> ApiResult { let client_id = ClientId::new(CONFIG.sso_client_id()); let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); let issuer_url = CONFIG.sso_issuer_url()?; let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { Err(err) => err!(format!("Failed to build http client: {err}")), Ok(client) => client, }; let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await { Err(err) => err!(format!("Failed to discover OpenID provider: {err}")), Ok(metadata) => metadata, }; let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); let token_uri = match base_client.token_uri() { Some(uri) => uri.clone(), None => err!("Failed to discover token_url, cannot proceed"), }; let user_info_url = match base_client.user_info_url() { Some(url) => url.clone(), None => err!("Failed to discover user_info url, cannot proceed"), }; let core_client = base_client .set_redirect_uri(CONFIG.sso_redirect_url()?) .set_token_uri(token_uri) .set_user_info_url(user_info_url); Ok(Client { http_client, core_client, }) } // Simple cache to prevent recalling the discovery endpoint each time pub async fn cached() -> ApiResult { if CONFIG.sso_client_cache_expiration() > 0 { match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) { Some(client) => Ok(client), None => Self::_get_client().await.inspect(|client| { debug!("Inserting new client in cache"); CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone()); }), } } else { Self::_get_client().await } } pub fn invalidate() { if CONFIG.sso_client_cache_expiration() > 0 { CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY); } } // The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier). pub async fn authorize_url( state: OIDCState, client_challenge: OIDCCodeChallenge, redirect_uri: String, ) -> ApiResult<(Url, SsoAuth)> { let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); let client = Self::cached().await?; let mut auth_req = client .core_client .authorize_url( AuthenticationFlow::::AuthorizationCode, || CsrfToken::new(base64_state), Nonce::new_random, ) .add_scopes(scopes) .add_extra_params(CONFIG.sso_authorize_extra_params_vec()); if CONFIG.sso_pkce() { auth_req = auth_req .add_extra_param::<&str, String>("code_challenge", client_challenge.clone().into()) .add_extra_param("code_challenge_method", "S256"); } let (auth_url, _, nonce) = auth_req.url(); Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri))) } pub async fn exchange_code( &self, code: OIDCCode, client_verifier: OIDCCodeVerifier, sso_auth: &SsoAuth, ) -> ApiResult<( StandardTokenResponse< IdTokenFields< EmptyAdditionalClaims, EmptyExtraTokenFields, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, >, CoreTokenType, >, IdTokenClaims, )> { let oidc_code = AuthorizationCode::new(code.to_string()); let mut exchange = self.core_client.exchange_code(oidc_code); let verifier = PkceCodeVerifier::new(client_verifier.into()); if CONFIG.sso_pkce() { exchange = exchange.set_pkce_verifier(verifier); } else { let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) { err!(format!("PKCE client challenge failed")) // Might need to notify admin ? how ? } } match exchange.request_async(&self.http_client).await { Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)), Ok(token_response) => { let oidc_nonce = Nonce::new(sso_auth.nonce.clone()); let id_token = match token_response.extra_fields().id_token() { None => err!("Token response did not contain an id_token"), Some(token) => token, }; if CONFIG.sso_debug_tokens() { debug!("Id token: {}", id_token.to_string()); debug!("Access token: {}", token_response.access_token().secret()); debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret())); debug!("Expiration time: {:?}", token_response.expires_in()); } let id_claims = match id_token.claims(&self.vw_id_token_verifier(), &oidc_nonce) { Ok(claims) => claims.clone(), Err(err) => { Self::invalidate(); err!(format!("Could not read id_token claims, {err}")); } }; Ok((token_response, id_claims)) } } } pub async fn user_info(&self, access_token: AccessToken) -> ApiResult { match self.core_client.user_info(access_token, None).request_async(&self.http_client).await { Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), Ok(user_info) => Ok(user_info), } } pub async fn check_validity(access_token: String) -> EmptyResult { let client = Client::cached().await?; match client.user_info(AccessToken::new(access_token)).await { Err(err) => { err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}")) } Ok(_) => Ok(()), } } pub fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> { let mut verifier = self.core_client.id_token_verifier(); if let Some(regex_str) = CONFIG.sso_audience_trusted() { match Regex::new(®ex_str) { Ok(regex) => { verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud)); } Err(err) => { error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}"); } } } verifier } pub async fn exchange_refresh_token(refresh_token: String) -> ApiResult { let client = Client::cached().await?; REFRESH_CACHE .get_with(refresh_token.clone(), async move { client._exchange_refresh_token(refresh_token).await }) .await .map_err(Into::into) } async fn _exchange_refresh_token(&self, refresh_token: String) -> Result { let rt = RefreshToken::new(refresh_token); match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await { Err(err) => { error!("Request to exchange_refresh_token endpoint failed: {err}"); Err(format!("Request to exchange_refresh_token endpoint failed: {err}")) } Ok(token_response) => Ok(( token_response.refresh_token().map(|token| token.secret().clone()), token_response.access_token().secret().clone(), token_response.expires_in(), )), } } } trait AuthorizationRequestExt<'a> { fn add_extra_params>, V: Into>>(self, params: Vec<(N, V)>) -> Self; } impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a> for AuthorizationRequest<'a, AD, P, RT> { fn add_extra_params>, V: Into>>(mut self, params: Vec<(N, V)>) -> Self { for (key, value) in params { self = self.add_extra_param(key, value); } self } } ================================================ FILE: src/static/global_domains.json ================================================ [ { "type": 2, "domains": [ "ameritrade.com", "tdameritrade.com" ], "excluded": false }, { "type": 3, "domains": [ "bankofamerica.com", "bofa.com", "mbna.com", "usecfo.com" ], "excluded": false }, { "type": 4, "domains": [ "sprint.com", "sprintpcs.com", "nextel.com" ], "excluded": false }, { "type": 0, "domains": [ "youtube.com", "google.com", "gmail.com" ], "excluded": false }, { "type": 1, "domains": [ "apple.com", "icloud.com" ], "excluded": false }, { "type": 5, "domains": [ "wellsfargo.com", "wf.com", "wellsfargoadvisors.com" ], "excluded": false }, { "type": 6, "domains": [ "mymerrill.com", "ml.com", "merrilledge.com" ], "excluded": false }, { "type": 7, "domains": [ "accountonline.com", "citi.com", "citibank.com", "citicards.com", "citibankonline.com" ], "excluded": false }, { "type": 8, "domains": [ "cnet.com", "cnettv.com", "com.com", "download.com", "news.com", "search.com", "upload.com" ], "excluded": false }, { "type": 9, "domains": [ "bananarepublic.com", "gap.com", "oldnavy.com", "piperlime.com" ], "excluded": false }, { "type": 10, "domains": [ "bing.com", "hotmail.com", "live.com", "microsoft.com", "msn.com", "passport.net", "windows.com", "microsoftonline.com", "office.com", "office365.com", "microsoftstore.com", "xbox.com", "azure.com", "windowsazure.com" ], "excluded": false }, { "type": 11, "domains": [ "ua2go.com", "ual.com", "united.com", "unitedwifi.com" ], "excluded": false }, { "type": 12, "domains": [ "overture.com", "yahoo.com" ], "excluded": false }, { "type": 13, "domains": [ "zonealarm.com", "zonelabs.com" ], "excluded": false }, { "type": 14, "domains": [ "paypal.com", "paypal-search.com" ], "excluded": false }, { "type": 15, "domains": [ "avon.com", "youravon.com" ], "excluded": false }, { "type": 16, "domains": [ "diapers.com", "soap.com", "wag.com", "yoyo.com", "beautybar.com", "casa.com", "afterschool.com", "vine.com", "bookworm.com", "look.com", "vinemarket.com" ], "excluded": false }, { "type": 17, "domains": [ "1800contacts.com", "800contacts.com" ], "excluded": false }, { "type": 18, "domains": [ "amazon.com", "amazon.com.be", "amazon.ae", "amazon.ca", "amazon.co.uk", "amazon.com.au", "amazon.com.br", "amazon.com.mx", "amazon.com.tr", "amazon.de", "amazon.es", "amazon.fr", "amazon.in", "amazon.it", "amazon.nl", "amazon.pl", "amazon.sa", "amazon.se", "amazon.sg" ], "excluded": false }, { "type": 19, "domains": [ "cox.com", "cox.net", "coxbusiness.com" ], "excluded": false }, { "type": 20, "domains": [ "mynortonaccount.com", "norton.com" ], "excluded": false }, { "type": 21, "domains": [ "verizon.com", "verizon.net" ], "excluded": false }, { "type": 22, "domains": [ "rakuten.com", "buy.com" ], "excluded": false }, { "type": 23, "domains": [ "siriusxm.com", "sirius.com" ], "excluded": false }, { "type": 24, "domains": [ "ea.com", "origin.com", "play4free.com", "tiberiumalliance.com" ], "excluded": false }, { "type": 25, "domains": [ "37signals.com", "basecamp.com", "basecamphq.com", "highrisehq.com" ], "excluded": false }, { "type": 26, "domains": [ "steampowered.com", "steamcommunity.com", "steamgames.com" ], "excluded": false }, { "type": 27, "domains": [ "chart.io", "chartio.com" ], "excluded": false }, { "type": 28, "domains": [ "gotomeeting.com", "citrixonline.com" ], "excluded": false }, { "type": 29, "domains": [ "gogoair.com", "gogoinflight.com" ], "excluded": false }, { "type": 30, "domains": [ "mysql.com", "oracle.com" ], "excluded": false }, { "type": 31, "domains": [ "discover.com", "discovercard.com" ], "excluded": false }, { "type": 32, "domains": [ "dcu.org", "dcu-online.org" ], "excluded": false }, { "type": 33, "domains": [ "healthcare.gov", "cuidadodesalud.gov", "cms.gov" ], "excluded": false }, { "type": 34, "domains": [ "pepco.com", "pepcoholdings.com" ], "excluded": false }, { "type": 35, "domains": [ "century21.com", "21online.com" ], "excluded": false }, { "type": 36, "domains": [ "comcast.com", "comcast.net", "xfinity.com" ], "excluded": false }, { "type": 37, "domains": [ "cricketwireless.com", "aiowireless.com" ], "excluded": false }, { "type": 38, "domains": [ "mandtbank.com", "mtb.com" ], "excluded": false }, { "type": 39, "domains": [ "dropbox.com", "getdropbox.com" ], "excluded": false }, { "type": 40, "domains": [ "snapfish.com", "snapfish.ca" ], "excluded": false }, { "type": 41, "domains": [ "alibaba.com", "aliexpress.com", "aliyun.com", "net.cn" ], "excluded": false }, { "type": 42, "domains": [ "playstation.com", "sonyentertainmentnetwork.com" ], "excluded": false }, { "type": 43, "domains": [ "mercadolivre.com", "mercadolivre.com.br", "mercadolibre.com", "mercadolibre.com.ar", "mercadolibre.com.mx" ], "excluded": false }, { "type": 44, "domains": [ "zendesk.com", "zopim.com" ], "excluded": false }, { "type": 45, "domains": [ "autodesk.com", "tinkercad.com" ], "excluded": false }, { "type": 46, "domains": [ "railnation.ru", "railnation.de", "rail-nation.com", "railnation.gr", "railnation.us", "trucknation.de", "traviangames.com" ], "excluded": false }, { "type": 47, "domains": [ "wpcu.coop", "wpcuonline.com" ], "excluded": false }, { "type": 48, "domains": [ "mathletics.com", "mathletics.com.au", "mathletics.co.uk" ], "excluded": false }, { "type": 49, "domains": [ "discountbank.co.il", "telebank.co.il" ], "excluded": false }, { "type": 50, "domains": [ "mi.com", "xiaomi.com" ], "excluded": false }, { "type": 52, "domains": [ "postepay.it", "poste.it" ], "excluded": false }, { "type": 51, "domains": [ "facebook.com", "messenger.com" ], "excluded": false }, { "type": 53, "domains": [ "skysports.com", "skybet.com", "skyvegas.com" ], "excluded": false }, { "type": 54, "domains": [ "disneymoviesanywhere.com", "go.com", "disney.com", "dadt.com", "disneyplus.com" ], "excluded": false }, { "type": 55, "domains": [ "pokemon-gl.com", "pokemon.com" ], "excluded": false }, { "type": 56, "domains": [ "myuv.com", "uvvu.com" ], "excluded": false }, { "type": 58, "domains": [ "mdsol.com", "imedidata.com" ], "excluded": false }, { "type": 57, "domains": [ "bank-yahav.co.il", "bankhapoalim.co.il" ], "excluded": false }, { "type": 59, "domains": [ "sears.com", "shld.net" ], "excluded": false }, { "type": 60, "domains": [ "xiami.com", "alipay.com" ], "excluded": false }, { "type": 61, "domains": [ "belkin.com", "seedonk.com" ], "excluded": false }, { "type": 62, "domains": [ "turbotax.com", "intuit.com" ], "excluded": false }, { "type": 63, "domains": [ "shopify.com", "myshopify.com" ], "excluded": false }, { "type": 64, "domains": [ "ebay.com", "ebay.at", "ebay.be", "ebay.ca", "ebay.ch", "ebay.cn", "ebay.co.jp", "ebay.co.th", "ebay.co.uk", "ebay.com.au", "ebay.com.hk", "ebay.com.my", "ebay.com.sg", "ebay.com.tw", "ebay.de", "ebay.es", "ebay.fr", "ebay.ie", "ebay.in", "ebay.it", "ebay.nl", "ebay.ph", "ebay.pl" ], "excluded": false }, { "type": 65, "domains": [ "techdata.com", "techdata.ch" ], "excluded": false }, { "type": 66, "domains": [ "schwab.com", "schwabplan.com" ], "excluded": false }, { "type": 68, "domains": [ "tesla.com", "teslamotors.com" ], "excluded": false }, { "type": 69, "domains": [ "morganstanley.com", "morganstanleyclientserv.com", "stockplanconnect.com", "ms.com" ], "excluded": false }, { "type": 70, "domains": [ "taxact.com", "taxactonline.com" ], "excluded": false }, { "type": 71, "domains": [ "mediawiki.org", "wikibooks.org", "wikidata.org", "wikimedia.org", "wikinews.org", "wikipedia.org", "wikiquote.org", "wikisource.org", "wikiversity.org", "wikivoyage.org", "wiktionary.org" ], "excluded": false }, { "type": 72, "domains": [ "airbnb.at", "airbnb.be", "airbnb.ca", "airbnb.ch", "airbnb.cl", "airbnb.co.cr", "airbnb.co.id", "airbnb.co.in", "airbnb.co.kr", "airbnb.co.nz", "airbnb.co.uk", "airbnb.co.ve", "airbnb.com", "airbnb.com.ar", "airbnb.com.au", "airbnb.com.bo", "airbnb.com.br", "airbnb.com.bz", "airbnb.com.co", "airbnb.com.ec", "airbnb.com.gt", "airbnb.com.hk", "airbnb.com.hn", "airbnb.com.mt", "airbnb.com.my", "airbnb.com.ni", "airbnb.com.pa", "airbnb.com.pe", "airbnb.com.py", "airbnb.com.sg", "airbnb.com.sv", "airbnb.com.tr", "airbnb.com.tw", "airbnb.cz", "airbnb.de", "airbnb.dk", "airbnb.es", "airbnb.fi", "airbnb.fr", "airbnb.gr", "airbnb.gy", "airbnb.hu", "airbnb.ie", "airbnb.is", "airbnb.it", "airbnb.jp", "airbnb.mx", "airbnb.nl", "airbnb.no", "airbnb.pl", "airbnb.pt", "airbnb.ru", "airbnb.se" ], "excluded": false }, { "type": 73, "domains": [ "eventbrite.at", "eventbrite.be", "eventbrite.ca", "eventbrite.ch", "eventbrite.cl", "eventbrite.co", "eventbrite.co.nz", "eventbrite.co.uk", "eventbrite.com", "eventbrite.com.ar", "eventbrite.com.au", "eventbrite.com.br", "eventbrite.com.mx", "eventbrite.com.pe", "eventbrite.de", "eventbrite.dk", "eventbrite.es", "eventbrite.fi", "eventbrite.fr", "eventbrite.hk", "eventbrite.ie", "eventbrite.it", "eventbrite.nl", "eventbrite.pt", "eventbrite.se", "eventbrite.sg" ], "excluded": false }, { "type": 74, "domains": [ "stackexchange.com", "superuser.com", "stackoverflow.com", "serverfault.com", "mathoverflow.net", "askubuntu.com", "stackapps.com" ], "excluded": false }, { "type": 75, "domains": [ "docusign.com", "docusign.net" ], "excluded": false }, { "type": 76, "domains": [ "envato.com", "themeforest.net", "codecanyon.net", "videohive.net", "audiojungle.net", "graphicriver.net", "photodune.net", "3docean.net" ], "excluded": false }, { "type": 77, "domains": [ "x10hosting.com", "x10premium.com" ], "excluded": false }, { "type": 78, "domains": [ "dnsomatic.com", "opendns.com", "umbrella.com" ], "excluded": false }, { "type": 79, "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": 80, "domains": [ "ubnt.com", "ui.com" ], "excluded": false }, { "type": 81, "domains": [ "discordapp.com", "discord.com" ], "excluded": false }, { "type": 82, "domains": [ "netcup.de", "netcup.eu", "customercontrolpanel.de" ], "excluded": false }, { "type": 83, "domains": [ "yandex.com", "ya.ru", "yandex.az", "yandex.by", "yandex.co.il", "yandex.com.am", "yandex.com.ge", "yandex.com.tr", "yandex.ee", "yandex.fi", "yandex.fr", "yandex.kg", "yandex.kz", "yandex.lt", "yandex.lv", "yandex.md", "yandex.pl", "yandex.ru", "yandex.tj", "yandex.tm", "yandex.ua", "yandex.uz" ], "excluded": false }, { "type": 84, "domains": [ "sonyentertainmentnetwork.com", "sony.com" ], "excluded": false }, { "type": 85, "domains": [ "proton.me", "protonmail.com", "protonvpn.com" ], "excluded": false }, { "type": 86, "domains": [ "ubisoft.com", "ubi.com" ], "excluded": false }, { "type": 87, "domains": [ "transferwise.com", "wise.com" ], "excluded": false }, { "type": 88, "domains": [ "takeaway.com", "just-eat.dk", "just-eat.no", "just-eat.fr", "just-eat.ch", "lieferando.de", "lieferando.at", "thuisbezorgd.nl", "pyszne.pl" ], "excluded": false }, { "type": 89, "domains": [ "atlassian.com", "bitbucket.org", "trello.com", "statuspage.io", "atlassian.net", "jira.com" ], "excluded": false }, { "type": 90, "domains": [ "pinterest.com", "pinterest.com.au", "pinterest.cl", "pinterest.de", "pinterest.dk", "pinterest.es", "pinterest.fr", "pinterest.co.uk", "pinterest.jp", "pinterest.co.kr", "pinterest.nz", "pinterest.pt", "pinterest.se" ], "excluded": false } ] ================================================ FILE: src/static/scripts/404.css ================================================ body { padding-top: 75px; } .vaultwarden-icon { width: 48px; height: 48px; height: 32px; width: auto; margin: -5px 0 0 0; } .footer { padding: 40px 0 40px 0; border-top: 1px solid #dee2e6; } .container { max-width: 980px; } .content { padding-top: 20px; padding-bottom: 20px; padding-left: 15px; padding-right: 15px; } .vw-404 { max-width: 500px; width: 100%; } ================================================ FILE: src/static/scripts/admin.css ================================================ body { padding-top: 75px; } img { width: 48px; height: 48px; } .vaultwarden-icon { height: 32px; width: auto; margin: -5px 0 0 0; } /* Special alert-row class to use Bootstrap v5.2+ variable colors */ .alert-row { --bs-alert-border: 1px solid var(--bs-alert-border-color); color: var(--bs-alert-color); background-color: var(--bs-alert-bg); border: var(--bs-alert-border); } #users-table .vw-account-details { min-width: 250px; } #users-table .vw-created-at, #users-table .vw-last-active { min-width: 85px; max-width: 85px; } #users-table .vw-entries, #orgs-table .vw-users, #orgs-table .vw-entries { min-width: 35px; max-width: 40px; } #orgs-table .vw-misc { min-width: 65px; max-width: 80px; } #users-table .vw-attachments, #orgs-table .vw-attachments { min-width: 100px; max-width: 130px; } #users-table .vw-actions, #orgs-table .vw-actions { min-width: 155px; max-width: 160px; } #users-table .vw-org-cell { max-height: 120px; } #orgs-table .vw-org-details { min-width: 285px; } #support-string { height: 16rem; } .vw-copy-toast { width: 15rem; } .abbr-badge { cursor: help; } .theme-icon, .theme-icon-active { display: inline-flex; flex: 0 0 1.75em; justify-content: center; } .theme-icon svg, .theme-icon-active svg { width: 1.25em; height: 1.25em; min-width: 1.25em; min-height: 1.25em; display: block; overflow: visible; } ================================================ FILE: src/static/scripts/admin.js ================================================ "use strict"; /* eslint-env es2017, browser */ /* exported BASE_URL, _post _delete */ function getBaseUrl() { // If the base URL is `https://vaultwarden.example.com/base/path/admin/`, // `window.location.href` should have one of the following forms: // // - `https://vaultwarden.example.com/base/path/admin` // - `https://vaultwarden.example.com/base/path/admin/#/some/route[?queryParam=...]` // // We want to get to just `https://vaultwarden.example.com/base/path`. const pathname = window.location.pathname; const adminPos = pathname.indexOf("/admin"); const newPathname = pathname.substring(0, adminPos != -1 ? adminPos : pathname.length); return `${window.location.origin}${newPathname}`; } const BASE_URL = getBaseUrl(); function reload() { // Reload the page by setting the exact same href // Using window.location.reload() could cause a repost. window.location = window.location.href; } function msg(text, reload_page = true) { text && alert(text); reload_page && reload(); } function _fetch(method, url, successMsg, errMsg, body, reload_page = true) { let respStatus; let respStatusText; fetch(url, { method: method, body: body, mode: "same-origin", credentials: "same-origin", headers: { "Content-Type": "application/json" } }).then(resp => { if (resp.ok) { msg(successMsg, reload_page); // Abuse the catch handler by setting error to false and continue return Promise.reject({ error: false }); } respStatus = resp.status; respStatusText = resp.statusText; return resp.text(); }).then(respText => { try { const respJson = JSON.parse(respText); if (respJson.errorModel && respJson.errorModel.message) { return respJson.errorModel.message; } else { return Promise.reject({ body: `${respStatus} - ${respStatusText}\n\nUnknown error`, error: true }); } } catch (e) { return Promise.reject({ body: `${respStatus} - ${respStatusText}\n\n[Catch] ${e}`, 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 _post(url, successMsg, errMsg, body, reload_page = true) { return _fetch("POST", url, successMsg, errMsg, body, reload_page); } function _delete(url, successMsg, errMsg, body, reload_page = true) { return _fetch("DELETE", url, successMsg, errMsg, body, reload_page); } // Bootstrap Theme Selector const getStoredTheme = () => localStorage.getItem("theme"); const setStoredTheme = theme => localStorage.setItem("theme", theme); const getPreferredTheme = () => { const storedTheme = getStoredTheme(); if (storedTheme) { return storedTheme; } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; const setTheme = theme => { if (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) { document.documentElement.setAttribute("data-bs-theme", "dark"); } else { document.documentElement.setAttribute("data-bs-theme", theme); } }; setTheme(getPreferredTheme()); const showActiveTheme = (theme, focus = false) => { const themeSwitcher = document.querySelector("#bd-theme"); if (!themeSwitcher) { return; } const themeSwitcherText = document.querySelector("#bd-theme-text"); const activeThemeIcon = document.querySelector(".theme-icon-active use"); const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`); if (!btnToActive) { return; } const btnIconUse = btnToActive ? btnToActive.querySelector("[data-theme-icon-use]") : null; const iconHref = btnIconUse ? btnIconUse.getAttribute("href") || btnIconUse.getAttribute("xlink:href") : null; document.querySelectorAll("[data-bs-theme-value]").forEach(element => { element.classList.remove("active"); element.setAttribute("aria-pressed", "false"); }); btnToActive.classList.add("active"); btnToActive.setAttribute("aria-pressed", "true"); if (iconHref && activeThemeIcon) { activeThemeIcon.setAttribute("href", iconHref); activeThemeIcon.setAttribute("xlink:href", iconHref); } const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`; themeSwitcher.setAttribute("aria-label", themeSwitcherLabel); if (focus) { themeSwitcher.focus(); } }; window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { const storedTheme = getStoredTheme(); if (storedTheme !== "light" && storedTheme !== "dark") { setTheme(getPreferredTheme()); } }); // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { showActiveTheme(getPreferredTheme()); document.querySelectorAll("[data-bs-theme-value]") .forEach(toggle => { toggle.addEventListener("click", () => { const theme = toggle.getAttribute("data-bs-theme-value"); setStoredTheme(theme); setTheme(theme); showActiveTheme(theme, true); }); }); // get current URL path and assign "active" class to the correct nav-item const pathname = window.location.pathname; if (pathname === "") return; const navItem = document.querySelectorAll(`.navbar-nav .nav-item a[href="${pathname}"]`); if (navItem.length === 1) { navItem[0].className = navItem[0].className + " active"; navItem[0].setAttribute("aria-current", "page"); } }); ================================================ FILE: src/static/scripts/admin_diagnostics.js ================================================ "use strict"; /* eslint-env es2017, browser */ /* global BASE_URL:readable, bootstrap:readable */ var dnsCheck = false; var timeCheck = false; var ntpTimeCheck = false; var domainCheck = false; var httpsCheck = false; var websocketCheck = false; var httpResponseCheck = false; // ================================ // Date & Time Check 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} UTC`; // ================================ // Check if the output is a valid IP function isValidIp(ip) { const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Regex = /^(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}|((?:[a-fA-F0-9]{1,4}:){1,7}:|:(:[a-fA-F0-9]{1,4}){1,7}|[a-fA-F0-9]{1,4}:((:[a-fA-F0-9]{1,4}){1,6}))$/; return ipv4Regex.test(ip) || ipv6Regex.test(ip); } function checkVersions(platform, installed, latest, commit=null, compare_order=0) { 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 (platform === "web" && compare_order === 1) { document.getElementById(`${platform}-prerelease`).classList.remove("d-none"); } else if (installed == latest) { document.getElementById(`${platform}-success`).classList.remove("d-none"); } else { document.getElementById(`${platform}-warning`).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"); } } } // ================================ // Generate support string to be pasted on github or the forum async function generateSupportString(event, dj) { event.preventDefault(); event.stopPropagation(); let supportString = "### Your environment (Generated via diagnostics page)\n\n"; supportString += `* Vaultwarden version: v${dj.current_release}\n`; supportString += `* Web-vault version: v${dj.active_web_release}\n`; supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`; supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database version: ${dj.db_version}\n`; supportString += `* Uses config.json: ${dj.overrides !== ""}\n`; supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; if (dj.ip_header_exists) { supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; } supportString += `* Internet access: ${dj.has_http_access}\n`; supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; supportString += `* DNS Check: ${dnsCheck}\n`; if (dj.tz_env !== "") { supportString += `* TZ environment: ${dj.tz_env}\n`; } supportString += `* Browser/Server Time Check: ${timeCheck}\n`; supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; supportString += `* Domain Configuration Check: ${domainCheck}\n`; supportString += `* HTTPS Check: ${httpsCheck}\n`; if (dj.enable_websocket) { supportString += `* Websocket Check: ${websocketCheck}\n`; } else { supportString += "* Websocket Check: disabled\n"; } supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { "headers": { "Accept": "application/json" } }); if (!jsonResponse.ok) { alert("Generation failed: " + jsonResponse.statusText); throw new Error(jsonResponse); } const configJson = await jsonResponse.json(); // Start Config and Details section within a details block which is collapsed by default supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "

Show Config & Details\n"; // Add overrides if they exists if (dj.overrides != "") { supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; } // Add http response check messages if they exists if (httpResponseCheck === false) { supportString += "\n**Failed HTTP Checks:**\n"; // We use `innerText` here since that will convert
into new-lines supportString += "\n```yaml\n" + document.getElementById("http-response-errors").innerText.trim() + "\n```\n"; } // Add the current config in json form supportString += "\n**Config:**\n"; supportString += "\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n"; supportString += "\n
\n"; // Add the support string to the textbox so it can be viewed and copied document.getElementById("support-string").textContent = supportString; document.getElementById("support-string").classList.remove("d-none"); document.getElementById("copy-support").classList.remove("d-none"); } function copyToClipboard(event) { event.preventDefault(); event.stopPropagation(); const supportStr = document.getElementById("support-string").textContent; const tmpCopyEl = document.createElement("textarea"); tmpCopyEl.setAttribute("id", "copy-support-string"); tmpCopyEl.setAttribute("readonly", ""); tmpCopyEl.value = supportStr; tmpCopyEl.style.position = "absolute"; tmpCopyEl.style.left = "-9999px"; document.body.appendChild(tmpCopyEl); tmpCopyEl.select(); document.execCommand("copy"); tmpCopyEl.remove(); new bootstrap.Toast("#toastClipboardCopy").show(); } function checkTimeDrift(utcTimeA, utcTimeB, statusPrefix) { const timeDrift = ( Date.parse(utcTimeA.replace(" ", "T").replace(" UTC", "")) - Date.parse(utcTimeB.replace(" ", "T").replace(" UTC", "")) ) / 1000; if (timeDrift > 15 || timeDrift < -15) { document.getElementById(`${statusPrefix}-warning`).classList.remove("d-none"); return false; } else { document.getElementById(`${statusPrefix}-success`).classList.remove("d-none"); return true; } } function checkDomain(browserURL, serverURL) { if (serverURL == browserURL) { document.getElementById("domain-success").classList.remove("d-none"); domainCheck = true; } else { document.getElementById("domain-warning").classList.remove("d-none"); } // Check for HTTPS at domain-server-string if (serverURL.startsWith("https://") ) { document.getElementById("https-success").classList.remove("d-none"); httpsCheck = true; } else { document.getElementById("https-warning").classList.remove("d-none"); } } function initVersionCheck(dj) { const serverInstalled = dj.current_release; const serverLatest = dj.latest_release; const serverLatestCommit = dj.latest_commit; if (serverInstalled.indexOf("-") !== -1 && serverLatest !== "-" && serverLatestCommit !== "-") { document.getElementById("server-latest-commit").classList.remove("d-none"); } checkVersions("server", serverInstalled, serverLatest, serverLatestCommit); const webInstalled = dj.active_web_release; const webLatest = dj.latest_web_release; checkVersions("web", webInstalled, webLatest, null, dj.web_vault_compare); } function checkDns(dns_resolved) { if (isValidIp(dns_resolved)) { document.getElementById("dns-success").classList.remove("d-none"); dnsCheck = true; } else { document.getElementById("dns-warning").classList.remove("d-none"); } } async function fetchCheckUrl(url) { try { const response = await fetch(url); return { headers: response.headers, status: response.status, text: await response.text() }; } catch (error) { console.error(`Error fetching ${url}: ${error}`); return { error }; } } function checkSecurityHeaders(headers, omit) { let securityHeaders = { "x-frame-options": ["SAMEORIGIN"], "x-content-type-options": ["nosniff"], "referrer-policy": ["same-origin"], "x-xss-protection": ["0"], "x-robots-tag": ["noindex", "nofollow"], "cross-origin-resource-policy": ["same-origin"], "content-security-policy": [ "default-src 'none'", "font-src 'self'", "manifest-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'self' blob:", "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", "child-src 'self' https://*.duosecurity.com https://*.duofederal.com", "frame-src 'self' https://*.duosecurity.com https://*.duofederal.com", "frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh moz-extension://*", "img-src 'self' data: https://haveibeenpwned.com", "connect-src 'self' https://api.pwnedpasswords.com https://api.2fa.directory https://app.simplelogin.io/api/ https://app.addy.io/api/ https://api.fastmail.com/ https://api.forwardemail.net", ] }; let messages = []; for (let header in securityHeaders) { // Skip some headers for specific endpoints if needed if (typeof omit === "object" && omit.includes(header) === true) { continue; } // If the header exists, check if the contents matches what we expect it to be let headerValue = headers.get(header); if (headerValue !== null) { securityHeaders[header].forEach((expectedValue) => { if (headerValue.indexOf(expectedValue) === -1) { messages.push(`'${header}' does not contain '${expectedValue}'`); } }); } else { messages.push(`'${header}' is missing!`); } } return messages; } async function checkHttpResponse() { const [apiConfig, webauthnConnector, notFound, notFoundApi, badRequest, unauthorized, forbidden] = await Promise.all([ fetchCheckUrl(`${BASE_URL}/api/config`), fetchCheckUrl(`${BASE_URL}/webauthn-connector.html`), fetchCheckUrl(`${BASE_URL}/admin/does-not-exist`), fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=404`), fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=400`), fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=401`), fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=403`), ]); const respErrorElm = document.getElementById("http-response-errors"); // Check and validate the default API header responses let apiErrors = checkSecurityHeaders(apiConfig.headers); if (apiErrors.length >= 1) { respErrorElm.innerHTML += "API calls:
"; apiErrors.forEach((errMsg) => { respErrorElm.innerHTML += `Header: ${errMsg}
`; }); } // Check the special `-connector.html` headers, these should have some headers omitted. const omitConnectorHeaders = ["x-frame-options", "content-security-policy"]; let connectorErrors = checkSecurityHeaders(webauthnConnector.headers, omitConnectorHeaders); omitConnectorHeaders.forEach((header) => { if (webauthnConnector.headers.get(header) !== null) { connectorErrors.push(`'${header}' is present while it should not`); } }); if (connectorErrors.length >= 1) { respErrorElm.innerHTML += "2FA Connector calls:
"; connectorErrors.forEach((errMsg) => { respErrorElm.innerHTML += `Header: ${errMsg}
`; }); } // Check specific error code responses if they are not re-written by a reverse proxy let responseErrors = []; if (notFound.status !== 404 || notFound.text.indexOf("return to the web-vault") === -1) { responseErrors.push("404 (Not Found) HTML is invalid"); } if (notFoundApi.status !== 404 || notFoundApi.text.indexOf("\"message\":\"Testing error 404 response\",") === -1) { responseErrors.push("404 (Not Found) JSON is invalid"); } if (badRequest.status !== 400 || badRequest.text.indexOf("\"message\":\"Testing error 400 response\",") === -1) { responseErrors.push("400 (Bad Request) is invalid"); } if (unauthorized.status !== 401 || unauthorized.text.indexOf("\"message\":\"Testing error 401 response\",") === -1) { responseErrors.push("401 (Unauthorized) is invalid"); } if (forbidden.status !== 403 || forbidden.text.indexOf("\"message\":\"Testing error 403 response\",") === -1) { responseErrors.push("403 (Forbidden) is invalid"); } if (responseErrors.length >= 1) { respErrorElm.innerHTML += "HTTP error responses:
"; responseErrors.forEach((errMsg) => { respErrorElm.innerHTML += `Response to: ${errMsg}
`; }); } if (responseErrors.length >= 1 || connectorErrors.length >= 1 || apiErrors.length >= 1) { document.getElementById("http-response-warning").classList.remove("d-none"); } else { httpResponseCheck = true; document.getElementById("http-response-success").classList.remove("d-none"); } } async function fetchWsUrl(wsUrl) { return new Promise((resolve, reject) => { try { const ws = new WebSocket(wsUrl); ws.onopen = () => { ws.close(); resolve(true); }; ws.onerror = () => { reject(false); }; } catch (_) { reject(false); } }); } async function checkWebsocketConnection() { // Test Websocket connections via the anonymous (login with device) connection const isConnected = await fetchWsUrl(`${BASE_URL}/notifications/anonymous-hub?token=admin-diagnostics`).catch(() => false); if (isConnected) { websocketCheck = true; document.getElementById("websocket-success").classList.remove("d-none"); } else { document.getElementById("websocket-error").classList.remove("d-none"); } } function init(dj) { // Time check document.getElementById("time-browser-string").textContent = browserUTC; // Check if we were able to fetch a valid NTP Time // If so, compare both browser and server with NTP // Else, compare browser and server. if (dj.ntp_time.indexOf("UTC") !== -1) { timeCheck = checkTimeDrift(dj.server_time, browserUTC, "time"); checkTimeDrift(dj.ntp_time, browserUTC, "ntp-browser"); ntpTimeCheck = checkTimeDrift(dj.ntp_time, dj.server_time, "ntp-server"); } else { timeCheck = checkTimeDrift(dj.server_time, browserUTC, "time"); ntpTimeCheck = "n/a"; } // Domain check const browserURL = location.href.toLowerCase(); document.getElementById("domain-browser-string").textContent = browserURL; checkDomain(browserURL, dj.admin_url.toLowerCase()); // Version check initVersionCheck(dj); // DNS Check checkDns(dj.dns_resolved); checkHttpResponse(); if (dj.enable_websocket) { checkWebsocketConnection(); } } // onLoad events document.addEventListener("DOMContentLoaded", (event) => { const diag_json = JSON.parse(document.getElementById("diagnostics_json").textContent); init(diag_json); const btnGenSupport = document.getElementById("gen-support"); if (btnGenSupport) { btnGenSupport.addEventListener("click", () => { generateSupportString(event, diag_json); }); } const btnCopySupport = document.getElementById("copy-support"); if (btnCopySupport) { btnCopySupport.addEventListener("click", copyToClipboard); } }); ================================================ FILE: src/static/scripts/admin_organizations.js ================================================ "use strict"; /* eslint-env es2017, browser, jquery */ /* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */ function deleteOrganization(event) { event.preventDefault(); event.stopPropagation(); const org_uuid = event.target.dataset.vwOrgUuid; const org_name = event.target.dataset.vwOrgName; const billing_email = event.target.dataset.vwBillingEmail; if (!org_uuid) { alert("Required parameters not found!"); return false; } // First make sure the user wants to delete this organization const continueDelete = confirm(`WARNING: All data of this organization (${org_name}) will be lost!\nMake sure you have a backup, this cannot be undone!`); if (continueDelete == true) { const input_org_uuid = prompt(`To delete the organization "${org_name} (${billing_email})", please type the organization uuid below.`); if (input_org_uuid != null) { if (input_org_uuid == org_uuid) { _post(`${BASE_URL}/admin/organizations/${org_uuid}/delete`, "Organization deleted correctly", "Error deleting organization" ); } else { alert("Wrong organization uuid, please try again"); } } } } function initActions() { document.querySelectorAll("button[vw-delete-organization]").forEach(btn => { btn.addEventListener("click", deleteOrganization); }); if (jdenticon) { jdenticon(); } } // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { jQuery("#orgs-table").DataTable({ "drawCallback": function() { initActions(); }, "stateSave": true, "responsive": true, "lengthMenu": [ [-1, 5, 10, 25, 50], ["All", 5, 10, 25, 50] ], "pageLength": -1, // Default show all "columnDefs": [{ "targets": [4,5], "searchable": false, "orderable": false }] }); // Add click events for organization actions initActions(); const btnReload = document.getElementById("reload"); if (btnReload) { btnReload.addEventListener("click", reload); } }); ================================================ FILE: src/static/scripts/admin_settings.js ================================================ "use strict"; /* eslint-env es2017, browser */ /* global _post:readable, BASE_URL:readable */ function smtpTest(event) { event.preventDefault(); event.stopPropagation(); if (formHasChanges(config_form)) { alert("Config has been changed but not yet saved.\nPlease save the changes first before sending a test email."); return false; } const test_email = document.getElementById("smtp-test-email"); // Do a very very basic email address check. if (test_email.value.match(/\S+@\S+/i) === null) { test_email.parentElement.classList.add("was-validated"); return false; } const data = JSON.stringify({ "email": test_email.value }); _post(`${BASE_URL}/admin/test/smtp`, "SMTP Test email sent correctly", "Error sending SMTP test email", data, false ); } function getFormData() { let data = {}; document.querySelectorAll(".conf-checkbox").forEach(function (e) { data[e.name] = e.checked; }); document.querySelectorAll(".conf-number").forEach(function (e) { data[e.name] = e.value ? +e.value : null; }); document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) { data[e.name] = e.value || null; }); return data; } function saveConfig(event) { const data = JSON.stringify(getFormData()); _post(`${BASE_URL}/admin/config`, "Config saved correctly", "Error saving config", data ); event.preventDefault(); } function deleteConf(event) { event.preventDefault(); event.stopPropagation(); const input = prompt( "This will remove all user configurations, and restore the defaults and the " + "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:" ); if (input === "DELETE") { _post(`${BASE_URL}/admin/config/delete`, "Config deleted correctly", "Error deleting config" ); } else { alert("Wrong input, please try again"); } } function backupDatabase(event) { event.preventDefault(); event.stopPropagation(); _post(`${BASE_URL}/admin/config/backup_db`, "Backup created successfully", "Error creating backup", null, false ); } // Two functions to help check if there were changes to the form fields // Useful for example during the smtp test to prevent people from clicking save before testing there new settings function initChangeDetection(form) { const ignore_fields = ["smtp-test-email"]; Array.from(form).forEach((el) => { if (! ignore_fields.includes(el.id)) { el.dataset.origValue = el.value; } }); } function formHasChanges(form) { return Array.from(form).some(el => "origValue" in el.dataset && ( el.dataset.origValue !== el.value)); } // This function will prevent submitting a from when someone presses enter. function preventFormSubmitOnEnter(form) { if (form) { form.addEventListener("keypress", (event) => { if (event.key == "Enter") { event.preventDefault(); } }); } } // This function will hook into the smtp-test-email input field and will call the smtpTest() function when enter is pressed. function submitTestEmailOnEnter() { const smtp_test_email_input = document.getElementById("smtp-test-email"); if (smtp_test_email_input) { smtp_test_email_input.addEventListener("keypress", (event) => { if (event.key == "Enter") { event.preventDefault(); smtpTest(event); } }); } } // Colorize some settings which are high risk function colorRiskSettings() { const risk_items = document.getElementsByClassName("col-form-label"); Array.from(risk_items).forEach((el) => { if (el.textContent.toLowerCase().includes("risks") ) { el.parentElement.className += " alert-danger"; } }); } function toggleVis(event) { event.preventDefault(); event.stopPropagation(); const elem = document.getElementById(event.target.dataset.vwPwToggle); const type = elem.getAttribute("type"); if (type === "text") { elem.setAttribute("type", "password"); } else { elem.setAttribute("type", "text"); } } function masterCheck(check_id, inputs_query) { function onChanged(checkbox, inputs_query) { return function _fn() { document.querySelectorAll(inputs_query).forEach(function (e) { e.disabled = !checkbox.checked; }); checkbox.disabled = false; }; } const checkbox = document.getElementById(check_id); if (checkbox) { const onChange = onChanged(checkbox, inputs_query); onChange(); // Trigger the event initially checkbox.addEventListener("change", onChange); } } // This will check if the ADMIN_TOKEN is not a Argon2 hashed value. // Else it will show a warning, unless someone has closed it. // Then it will not show this warning for 30 days. function checkAdminToken() { const admin_token = document.getElementById("input_admin_token"); const disable_admin_token = document.getElementById("input_disable_admin_token"); if (!disable_admin_token.checked && !admin_token.value.startsWith("$argon2")) { // Check if the warning has been closed before and 30 days have passed const admin_token_warning_closed = localStorage.getItem("admin_token_warning_closed"); if (admin_token_warning_closed !== null) { const closed_date = new Date(parseInt(admin_token_warning_closed)); const current_date = new Date(); const thirtyDays = 1000*60*60*24*30; if (current_date - closed_date < thirtyDays) { return; } } // When closing the alert, store the current date/time in the browser const admin_token_warning = document.getElementById("admin_token_warning"); admin_token_warning.addEventListener("closed.bs.alert", function() { const d = new Date(); localStorage.setItem("admin_token_warning_closed", d.getTime()); }); // Display the warning admin_token_warning.classList.remove("d-none"); } } // This will check for specific configured values, and when needed will show a warning div function showWarnings() { checkAdminToken(); } const config_form = document.getElementById("config-form"); // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { initChangeDetection(config_form); // Prevent enter to submitting the form and save the config. // Users need to really click on save, this also to prevent accidental submits. preventFormSubmitOnEnter(config_form); submitTestEmailOnEnter(); colorRiskSettings(); document.querySelectorAll("input[id^='input__enable_']").forEach(group_toggle => { const input_id = group_toggle.id.replace("input__enable_", "#g_"); masterCheck(group_toggle.id, `${input_id} input`); }); document.querySelectorAll("button[data-vw-pw-toggle]").forEach(password_toggle_btn => { password_toggle_btn.addEventListener("click", toggleVis); }); const btnBackupDatabase = document.getElementById("backupDatabase"); if (btnBackupDatabase) { btnBackupDatabase.addEventListener("click", backupDatabase); } const btnDeleteConf = document.getElementById("deleteConf"); if (btnDeleteConf) { btnDeleteConf.addEventListener("click", deleteConf); } const btnSmtpTest = document.getElementById("smtpTest"); if (btnSmtpTest) { btnSmtpTest.addEventListener("click", smtpTest); } config_form.addEventListener("submit", saveConfig); showWarnings(); }); ================================================ FILE: src/static/scripts/admin_users.js ================================================ "use strict"; /* eslint-env es2017, browser, jquery */ /* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */ function deleteUser(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const input_email = prompt(`To delete user "${email}", please type the email below`); if (input_email != null) { if (input_email == email) { _post(`${BASE_URL}/admin/users/${id}/delete`, "User deleted correctly", "Error deleting user" ); } else { alert("Wrong email, please try again"); } } } function deleteSSOUser(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const input_email = prompt(`To delete user "${email}" SSO association, please type the email below`); if (input_email != null) { if (input_email == email) { _delete(`${BASE_URL}/admin/users/${id}/sso`, "User SSO association deleted correctly", "Error deleting user SSO association" ); } else { alert("Wrong email, please try again"); } } } function remove2fa(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const confirmed = confirm(`Are you sure you want to remove 2FA for "${email}"?`); if (confirmed) { _post(`${BASE_URL}/admin/users/${id}/remove-2fa`, "2FA removed correctly", "Error removing 2FA" ); } } function deauthUser(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const confirmed = confirm(`Are you sure you want to deauthorize sessions for "${email}"?`); if (confirmed) { _post(`${BASE_URL}/admin/users/${id}/deauth`, "Sessions deauthorized correctly", "Error deauthorizing sessions" ); } } function disableUser(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const confirmed = confirm(`Are you sure you want to disable user "${email}"? This will also deauthorize their sessions.`); if (confirmed) { _post(`${BASE_URL}/admin/users/${id}/disable`, "User disabled successfully", "Error disabling user" ); } } function enableUser(event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const confirmed = confirm(`Are you sure you want to enable user "${email}"?`); if (confirmed) { _post(`${BASE_URL}/admin/users/${id}/enable`, "User enabled successfully", "Error enabling user" ); } } function updateRevisions(event) { event.preventDefault(); event.stopPropagation(); _post(`${BASE_URL}/admin/users/update_revision`, "Success, clients will sync next time they connect", "Error forcing clients to sync" ); } function inviteUser(event) { event.preventDefault(); event.stopPropagation(); const email = document.getElementById("inviteEmail"); const data = JSON.stringify({ "email": email.value }); email.value = ""; _post(`${BASE_URL}/admin/invite`, "User invited correctly", "Error inviting user", data ); } function resendUserInvite (event) { event.preventDefault(); event.stopPropagation(); const id = event.target.parentNode.dataset.vwUserUuid; const email = event.target.parentNode.dataset.vwUserEmail; if (!id || !email) { alert("Required parameters not found!"); return false; } const confirmed = confirm(`Are you sure you want to resend invitation for "${email}"?`); if (confirmed) { _post(`${BASE_URL}/admin/users/${id}/invite/resend`, "Invite sent successfully", "Error resend invite" ); } } const ORG_TYPES = { "0": { "name": "Owner", "bg": "orange", "font": "black" }, "1": { "name": "Admin", "bg": "blueviolet" }, "2": { "name": "User", "bg": "blue" }, "4": { "name": "Manager", "bg": "green" }, }; // Special sort function to sort dates in ISO format jQuery.extend(jQuery.fn.dataTableExt.oSort, { "date-iso-pre": function(a) { let x; const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim(); if (sortDate !== "") { const dtParts = sortDate.split(" "); const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"]; const dateParts = dtParts[0].split("-"); x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1; if (isNaN(x)) { x = 0; } } else { x = Infinity; } return x; }, "date-iso-asc": function(a, b) { return a - b; }, "date-iso-desc": function(a, b) { return b - a; } }); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); // Fill the form and title userOrgTypeDialog.addEventListener("show.bs.modal", function(event) { // Get shared values const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; // Get org specific values const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; const orgName = event.relatedTarget.dataset.vwOrgName; const orgUuid = event.relatedTarget.dataset.vwOrgUuid; document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid; document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; }, false); // Prevent accidental submission of the form with valid elements after the modal has been hidden. userOrgTypeDialog.addEventListener("hide.bs.modal", function() { document.getElementById("userOrgTypeDialogOrgName").textContent = ""; document.getElementById("userOrgTypeDialogUserEmail").textContent = ""; document.getElementById("userOrgTypeUserUuid").value = ""; document.getElementById("userOrgTypeOrgUuid").value = ""; }, false); function updateUserOrgType(event) { event.preventDefault(); event.stopPropagation(); const data = JSON.stringify(Object.fromEntries(new FormData(event.target).entries())); _post(`${BASE_URL}/admin/users/org_type`, "Updated organization type of the user successfully", "Error updating organization type of the user", data ); } function initUserTable() { // Color all the org buttons per type document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { const orgType = ORG_TYPES[e.dataset.vwOrgType]; e.style.backgroundColor = orgType.bg; if (orgType.font !== undefined) { e.style.color = orgType.font; } e.title = orgType.name; }); document.querySelectorAll("button[vw-remove2fa]").forEach(btn => { btn.addEventListener("click", remove2fa); }); document.querySelectorAll("button[vw-deauth-user]").forEach(btn => { btn.addEventListener("click", deauthUser); }); document.querySelectorAll("button[vw-delete-user]").forEach(btn => { btn.addEventListener("click", deleteUser); }); document.querySelectorAll("button[vw-delete-sso-user]").forEach(btn => { btn.addEventListener("click", deleteSSOUser); }); document.querySelectorAll("button[vw-disable-user]").forEach(btn => { btn.addEventListener("click", disableUser); }); document.querySelectorAll("button[vw-enable-user]").forEach(btn => { btn.addEventListener("click", enableUser); }); document.querySelectorAll("button[vw-resend-user-invite]").forEach(btn => { btn.addEventListener("click", resendUserInvite); }); if (jdenticon) { jdenticon(); } } // onLoad events document.addEventListener("DOMContentLoaded", (/*event*/) => { const size = jQuery("#users-table > thead th").length; const ssoOffset = size-7; jQuery("#users-table").DataTable({ "drawCallback": function() { initUserTable(); }, "stateSave": true, "responsive": true, "lengthMenu": [ [-1, 2, 5, 10, 25, 50], ["All", 2, 5, 10, 25, 50] ], "pageLength": -1, // Default show all "columnDefs": [{ "targets": [1 + ssoOffset, 2 + ssoOffset], "type": "date-iso" }, { "targets": size-1, "searchable": false, "orderable": false }] }); // Add click events for user actions initUserTable(); const btnUpdateRevisions = document.getElementById("updateRevisions"); if (btnUpdateRevisions) { btnUpdateRevisions.addEventListener("click", updateRevisions); } const btnReload = document.getElementById("reload"); if (btnReload) { btnReload.addEventListener("click", reload); } const btnUserOrgTypeForm = document.getElementById("userOrgTypeForm"); if (btnUserOrgTypeForm) { btnUserOrgTypeForm.addEventListener("submit", updateUserOrgType); } const btnInviteUserForm = document.getElementById("inviteUserForm"); if (btnInviteUserForm) { btnInviteUserForm.addEventListener("submit", inviteUser); } }); ================================================ FILE: src/static/scripts/bootstrap.bundle.js ================================================ /*! * Bootstrap v5.3.8 (https://getbootstrap.com/) * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.bootstrap = factory()); })(this, (function () { 'use strict'; /** * -------------------------------------------------------------------------- * Bootstrap dom/data.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const elementMap = new Map(); const Data = { set(element, key, instance) { if (!elementMap.has(element)) { elementMap.set(element, new Map()); } const instanceMap = elementMap.get(element); // make it clear we only want one instance per element // can be removed later when multiple key/instances are fine to be used if (!instanceMap.has(key) && instanceMap.size !== 0) { // eslint-disable-next-line no-console console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`); return; } instanceMap.set(key, instance); }, get(element, key) { if (elementMap.has(element)) { return elementMap.get(element).get(key) || null; } return null; }, remove(element, key) { if (!elementMap.has(element)) { return; } const instanceMap = elementMap.get(element); instanceMap.delete(key); // free up element references if there are no instances left for an element if (instanceMap.size === 0) { elementMap.delete(element); } } }; /** * -------------------------------------------------------------------------- * Bootstrap util/index.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ const MAX_UID = 1000000; const MILLISECONDS_MULTIPLIER = 1000; const TRANSITION_END = 'transitionend'; /** * Properly escape IDs selectors to handle weird IDs * @param {string} selector * @returns {string} */ const parseSelector = selector => { if (selector && window.CSS && window.CSS.escape) { // document.querySelector needs escaping to handle IDs (html5+) containing for instance / selector = selector.replace(/#([^\s"#']+)/g, (match, id) => `#${CSS.escape(id)}`); } return selector; }; // Shout-out Angus Croll (https://goo.gl/pxwQGp) const toType = object => { if (object === null || object === undefined) { return `${object}`; } return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)[1].toLowerCase(); }; /** * Public Util API */ const getUID = prefix => { do { prefix += Math.floor(Math.random() * MAX_UID); } while (document.getElementById(prefix)); return prefix; }; const getTransitionDurationFromElement = element => { if (!element) { return 0; } // Get transition-duration of the element let { transitionDuration, transitionDelay } = window.getComputedStyle(element); const floatTransitionDuration = Number.parseFloat(transitionDuration); const floatTransitionDelay = Number.parseFloat(transitionDelay); // Return 0 if element or transition duration is not found if (!floatTransitionDuration && !floatTransitionDelay) { return 0; } // If multiple durations are defined, take the first transitionDuration = transitionDuration.split(',')[0]; transitionDelay = transitionDelay.split(',')[0]; return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER; }; const triggerTransitionEnd = element => { element.dispatchEvent(new Event(TRANSITION_END)); }; const isElement$1 = object => { if (!object || typeof object !== 'object') { return false; } if (typeof object.jquery !== 'undefined') { object = object[0]; } return typeof object.nodeType !== 'undefined'; }; const getElement = object => { // it's a jQuery object or a node element if (isElement$1(object)) { return object.jquery ? object[0] : object; } if (typeof object === 'string' && object.length > 0) { return document.querySelector(parseSelector(object)); } return null; }; const isVisible = element => { if (!isElement$1(element) || element.getClientRects().length === 0) { return false; } const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'; // Handle `details` element as its content may falsie appear visible when it is closed const closedDetails = element.closest('details:not([open])'); if (!closedDetails) { return elementIsVisible; } if (closedDetails !== element) { const summary = element.closest('summary'); if (summary && summary.parentNode !== closedDetails) { return false; } if (summary === null) { return false; } } return elementIsVisible; }; const isDisabled = element => { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return true; } if (element.classList.contains('disabled')) { return true; } if (typeof element.disabled !== 'undefined') { return element.disabled; } return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'; }; const findShadowRoot = element => { if (!document.documentElement.attachShadow) { return null; } // Can find the shadow root otherwise it'll return the document if (typeof element.getRootNode === 'function') { const root = element.getRootNode(); return root instanceof ShadowRoot ? root : null; } if (element instanceof ShadowRoot) { return element; } // when we don't find a shadow root if (!element.parentNode) { return null; } return findShadowRoot(element.parentNode); }; const noop = () => {}; /** * Trick to restart an element's animation * * @param {HTMLElement} element * @return void * * @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation */ const reflow = element => { element.offsetHeight; // eslint-disable-line no-unused-expressions }; const getjQuery = () => { if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) { return window.jQuery; } return null; }; const DOMContentLoadedCallbacks = []; const onDOMContentLoaded = callback => { if (document.readyState === 'loading') { // add listener on the first call when the document is in loading state if (!DOMContentLoadedCallbacks.length) { document.addEventListener('DOMContentLoaded', () => { for (const callback of DOMContentLoadedCallbacks) { callback(); } }); } DOMContentLoadedCallbacks.push(callback); } else { callback(); } }; const isRTL = () => document.documentElement.dir === 'rtl'; const defineJQueryPlugin = plugin => { onDOMContentLoaded(() => { const $ = getjQuery(); /* istanbul ignore if */ if ($) { const name = plugin.NAME; const JQUERY_NO_CONFLICT = $.fn[name]; $.fn[name] = plugin.jQueryInterface; $.fn[name].Constructor = plugin; $.fn[name].noConflict = () => { $.fn[name] = JQUERY_NO_CONFLICT; return plugin.jQueryInterface; }; } }); }; const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => { return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue; }; const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => { if (!waitForTransition) { execute(callback); return; } const durationPadding = 5; const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding; let called = false; const handler = ({ target }) => { if (target !== transitionElement) { return; } called = true; transitionElement.removeEventListener(TRANSITION_END, handler); execute(callback); }; transitionElement.addEventListener(TRANSITION_END, handler); setTimeout(() => { if (!called) { triggerTransitionEnd(transitionElement); } }, emulatedDuration); }; /** * Return the previous/next element of a list. * * @param {array} list The list of elements * @param activeElement The active element * @param shouldGetNext Choose to get next or previous element * @param isCycleAllowed * @return {Element|elem} The proper element */ const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => { const listLength = list.length; let index = list.indexOf(activeElement); // if the element does not exist in the list return an element // depending on the direction and if cycle is allowed if (index === -1) { return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]; } index += shouldGetNext ? 1 : -1; if (isCycleAllowed) { index = (index + listLength) % listLength; } return list[Math.max(0, Math.min(index, listLength - 1))]; }; /** * -------------------------------------------------------------------------- * Bootstrap dom/event-handler.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const namespaceRegex = /[^.]*(?=\..*)\.|.*/; const stripNameRegex = /\..*/; const stripUidRegex = /::\d+$/; const eventRegistry = {}; // Events storage let uidEvent = 1; const customEvents = { mouseenter: 'mouseover', mouseleave: 'mouseout' }; const nativeEvents = new Set(['click', 'dblclick', 'mouseup', 'mousedown', 'contextmenu', 'mousewheel', 'DOMMouseScroll', 'mouseover', 'mouseout', 'mousemove', 'selectstart', 'selectend', 'keydown', 'keypress', 'keyup', 'orientationchange', 'touchstart', 'touchmove', 'touchend', 'touchcancel', 'pointerdown', 'pointermove', 'pointerup', 'pointerleave', 'pointercancel', 'gesturestart', 'gesturechange', 'gestureend', 'focus', 'blur', 'change', 'reset', 'select', 'submit', 'focusin', 'focusout', 'load', 'unload', 'beforeunload', 'resize', 'move', 'DOMContentLoaded', 'readystatechange', 'error', 'abort', 'scroll']); /** * Private methods */ function makeEventUid(element, uid) { return uid && `${uid}::${uidEvent++}` || element.uidEvent || uidEvent++; } function getElementEvents(element) { const uid = makeEventUid(element); element.uidEvent = uid; eventRegistry[uid] = eventRegistry[uid] || {}; return eventRegistry[uid]; } function bootstrapHandler(element, fn) { return function handler(event) { hydrateObj(event, { delegateTarget: element }); if (handler.oneOff) { EventHandler.off(element, event.type, fn); } return fn.apply(element, [event]); }; } function bootstrapDelegationHandler(element, selector, fn) { return function handler(event) { const domElements = element.querySelectorAll(selector); for (let { target } = event; target && target !== this; target = target.parentNode) { for (const domElement of domElements) { if (domElement !== target) { continue; } hydrateObj(event, { delegateTarget: target }); if (handler.oneOff) { EventHandler.off(element, event.type, selector, fn); } return fn.apply(target, [event]); } } }; } function findHandler(events, callable, delegationSelector = null) { return Object.values(events).find(event => event.callable === callable && event.delegationSelector === delegationSelector); } function normalizeParameters(originalTypeEvent, handler, delegationFunction) { const isDelegated = typeof handler === 'string'; // TODO: tooltip passes `false` instead of selector, so we need to check const callable = isDelegated ? delegationFunction : handler || delegationFunction; let typeEvent = getTypeEvent(originalTypeEvent); if (!nativeEvents.has(typeEvent)) { typeEvent = originalTypeEvent; } return [isDelegated, callable, typeEvent]; } function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) { if (typeof originalTypeEvent !== 'string' || !element) { return; } let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction); // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position // this prevents the handler from being dispatched the same way as mouseover or mouseout does if (originalTypeEvent in customEvents) { const wrapFunction = fn => { return function (event) { if (!event.relatedTarget || event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget)) { return fn.call(this, event); } }; }; callable = wrapFunction(callable); } const events = getElementEvents(element); const handlers = events[typeEvent] || (events[typeEvent] = {}); const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null); if (previousFunction) { previousFunction.oneOff = previousFunction.oneOff && oneOff; return; } const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, '')); const fn = isDelegated ? bootstrapDelegationHandler(element, handler, callable) : bootstrapHandler(element, callable); fn.delegationSelector = isDelegated ? handler : null; fn.callable = callable; fn.oneOff = oneOff; fn.uidEvent = uid; handlers[uid] = fn; element.addEventListener(typeEvent, fn, isDelegated); } function removeHandler(element, events, typeEvent, handler, delegationSelector) { const fn = findHandler(events[typeEvent], handler, delegationSelector); if (!fn) { return; } element.removeEventListener(typeEvent, fn, Boolean(delegationSelector)); delete events[typeEvent][fn.uidEvent]; } function removeNamespacedHandlers(element, events, typeEvent, namespace) { const storeElementEvent = events[typeEvent] || {}; for (const [handlerKey, event] of Object.entries(storeElementEvent)) { if (handlerKey.includes(namespace)) { removeHandler(element, events, typeEvent, event.callable, event.delegationSelector); } } } function getTypeEvent(event) { // allow to get the native events from namespaced events ('click.bs.button' --> 'click') event = event.replace(stripNameRegex, ''); return customEvents[event] || event; } const EventHandler = { on(element, event, handler, delegationFunction) { addHandler(element, event, handler, delegationFunction, false); }, one(element, event, handler, delegationFunction) { addHandler(element, event, handler, delegationFunction, true); }, off(element, originalTypeEvent, handler, delegationFunction) { if (typeof originalTypeEvent !== 'string' || !element) { return; } const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction); const inNamespace = typeEvent !== originalTypeEvent; const events = getElementEvents(element); const storeElementEvent = events[typeEvent] || {}; const isNamespace = originalTypeEvent.startsWith('.'); if (typeof callable !== 'undefined') { // Simplest case: handler is passed, remove that listener ONLY. if (!Object.keys(storeElementEvent).length) { return; } removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null); return; } if (isNamespace) { for (const elementEvent of Object.keys(events)) { removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1)); } } for (const [keyHandlers, event] of Object.entries(storeElementEvent)) { const handlerKey = keyHandlers.replace(stripUidRegex, ''); if (!inNamespace || originalTypeEvent.includes(handlerKey)) { removeHandler(element, events, typeEvent, event.callable, event.delegationSelector); } } }, trigger(element, event, args) { if (typeof event !== 'string' || !element) { return null; } const $ = getjQuery(); const typeEvent = getTypeEvent(event); const inNamespace = event !== typeEvent; let jQueryEvent = null; let bubbles = true; let nativeDispatch = true; let defaultPrevented = false; if (inNamespace && $) { jQueryEvent = $.Event(event, args); $(element).trigger(jQueryEvent); bubbles = !jQueryEvent.isPropagationStopped(); nativeDispatch = !jQueryEvent.isImmediatePropagationStopped(); defaultPrevented = jQueryEvent.isDefaultPrevented(); } const evt = hydrateObj(new Event(event, { bubbles, cancelable: true }), args); if (defaultPrevented) { evt.preventDefault(); } if (nativeDispatch) { element.dispatchEvent(evt); } if (evt.defaultPrevented && jQueryEvent) { jQueryEvent.preventDefault(); } return evt; } }; function hydrateObj(obj, meta = {}) { for (const [key, value] of Object.entries(meta)) { try { obj[key] = value; } catch (_unused) { Object.defineProperty(obj, key, { configurable: true, get() { return value; } }); } } return obj; } /** * -------------------------------------------------------------------------- * Bootstrap dom/manipulator.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ function normalizeData(value) { if (value === 'true') { return true; } if (value === 'false') { return false; } if (value === Number(value).toString()) { return Number(value); } if (value === '' || value === 'null') { return null; } if (typeof value !== 'string') { return value; } try { return JSON.parse(decodeURIComponent(value)); } catch (_unused) { return value; } } function normalizeDataKey(key) { return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`); } const Manipulator = { setDataAttribute(element, key, value) { element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value); }, removeDataAttribute(element, key) { element.removeAttribute(`data-bs-${normalizeDataKey(key)}`); }, getDataAttributes(element) { if (!element) { return {}; } const attributes = {}; const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig')); for (const key of bsKeys) { let pureKey = key.replace(/^bs/, ''); pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1); attributes[pureKey] = normalizeData(element.dataset[key]); } return attributes; }, getDataAttribute(element, key) { return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`)); } }; /** * -------------------------------------------------------------------------- * Bootstrap util/config.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Class definition */ class Config { // Getters static get Default() { return {}; } static get DefaultType() { return {}; } static get NAME() { throw new Error('You have to implement the static method "NAME", for each component!'); } _getConfig(config) { config = this._mergeConfigObj(config); config = this._configAfterMerge(config); this._typeCheckConfig(config); return config; } _configAfterMerge(config) { return config; } _mergeConfigObj(config, element) { const jsonConfig = isElement$1(element) ? Manipulator.getDataAttribute(element, 'config') : {}; // try to parse return { ...this.constructor.Default, ...(typeof jsonConfig === 'object' ? jsonConfig : {}), ...(isElement$1(element) ? Manipulator.getDataAttributes(element) : {}), ...(typeof config === 'object' ? config : {}) }; } _typeCheckConfig(config, configTypes = this.constructor.DefaultType) { for (const [property, expectedTypes] of Object.entries(configTypes)) { const value = config[property]; const valueType = isElement$1(value) ? 'element' : toType(value); if (!new RegExp(expectedTypes).test(valueType)) { throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`); } } } } /** * -------------------------------------------------------------------------- * Bootstrap base-component.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const VERSION = '5.3.8'; /** * Class definition */ class BaseComponent extends Config { constructor(element, config) { super(); element = getElement(element); if (!element) { return; } this._element = element; this._config = this._getConfig(config); Data.set(this._element, this.constructor.DATA_KEY, this); } // Public dispose() { Data.remove(this._element, this.constructor.DATA_KEY); EventHandler.off(this._element, this.constructor.EVENT_KEY); for (const propertyName of Object.getOwnPropertyNames(this)) { this[propertyName] = null; } } // Private _queueCallback(callback, element, isAnimated = true) { executeAfterTransition(callback, element, isAnimated); } _getConfig(config) { config = this._mergeConfigObj(config, this._element); config = this._configAfterMerge(config); this._typeCheckConfig(config); return config; } // Static static getInstance(element) { return Data.get(getElement(element), this.DATA_KEY); } static getOrCreateInstance(element, config = {}) { return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null); } static get VERSION() { return VERSION; } static get DATA_KEY() { return `bs.${this.NAME}`; } static get EVENT_KEY() { return `.${this.DATA_KEY}`; } static eventName(name) { return `${name}${this.EVENT_KEY}`; } } /** * -------------------------------------------------------------------------- * Bootstrap dom/selector-engine.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ const getSelector = element => { let selector = element.getAttribute('data-bs-target'); if (!selector || selector === '#') { let hrefAttribute = element.getAttribute('href'); // The only valid content that could double as a selector are IDs or classes, // so everything starting with `#` or `.`. If a "real" URL is used as the selector, // `document.querySelector` will rightfully complain it is invalid. // See https://github.com/twbs/bootstrap/issues/32273 if (!hrefAttribute || !hrefAttribute.includes('#') && !hrefAttribute.startsWith('.')) { return null; } // Just in case some CMS puts out a full URL with the anchor appended if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) { hrefAttribute = `#${hrefAttribute.split('#')[1]}`; } selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null; } return selector ? selector.split(',').map(sel => parseSelector(sel)).join(',') : null; }; const SelectorEngine = { find(selector, element = document.documentElement) { return [].concat(...Element.prototype.querySelectorAll.call(element, selector)); }, findOne(selector, element = document.documentElement) { return Element.prototype.querySelector.call(element, selector); }, children(element, selector) { return [].concat(...element.children).filter(child => child.matches(selector)); }, parents(element, selector) { const parents = []; let ancestor = element.parentNode.closest(selector); while (ancestor) { parents.push(ancestor); ancestor = ancestor.parentNode.closest(selector); } return parents; }, prev(element, selector) { let previous = element.previousElementSibling; while (previous) { if (previous.matches(selector)) { return [previous]; } previous = previous.previousElementSibling; } return []; }, // TODO: this is now unused; remove later along with prev() next(element, selector) { let next = element.nextElementSibling; while (next) { if (next.matches(selector)) { return [next]; } next = next.nextElementSibling; } return []; }, focusableChildren(element) { const focusables = ['a', 'button', 'input', 'textarea', 'select', 'details', '[tabindex]', '[contenteditable="true"]'].map(selector => `${selector}:not([tabindex^="-"])`).join(','); return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el)); }, getSelectorFromElement(element) { const selector = getSelector(element); if (selector) { return SelectorEngine.findOne(selector) ? selector : null; } return null; }, getElementFromSelector(element) { const selector = getSelector(element); return selector ? SelectorEngine.findOne(selector) : null; }, getMultipleElementsFromSelector(element) { const selector = getSelector(element); return selector ? SelectorEngine.find(selector) : []; } }; /** * -------------------------------------------------------------------------- * Bootstrap util/component-functions.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ const enableDismissTrigger = (component, method = 'hide') => { const clickEvent = `click.dismiss${component.EVENT_KEY}`; const name = component.NAME; EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) { if (['A', 'AREA'].includes(this.tagName)) { event.preventDefault(); } if (isDisabled(this)) { return; } const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`); const instance = component.getOrCreateInstance(target); // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method instance[method](); }); }; /** * -------------------------------------------------------------------------- * Bootstrap alert.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$f = 'alert'; const DATA_KEY$a = 'bs.alert'; const EVENT_KEY$b = `.${DATA_KEY$a}`; const EVENT_CLOSE = `close${EVENT_KEY$b}`; const EVENT_CLOSED = `closed${EVENT_KEY$b}`; const CLASS_NAME_FADE$5 = 'fade'; const CLASS_NAME_SHOW$8 = 'show'; /** * Class definition */ class Alert extends BaseComponent { // Getters static get NAME() { return NAME$f; } // Public close() { const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE); if (closeEvent.defaultPrevented) { return; } this._element.classList.remove(CLASS_NAME_SHOW$8); const isAnimated = this._element.classList.contains(CLASS_NAME_FADE$5); this._queueCallback(() => this._destroyElement(), this._element, isAnimated); } // Private _destroyElement() { this._element.remove(); EventHandler.trigger(this._element, EVENT_CLOSED); this.dispose(); } // Static static jQueryInterface(config) { return this.each(function () { const data = Alert.getOrCreateInstance(this); if (typeof config !== 'string') { return; } if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { throw new TypeError(`No method named "${config}"`); } data[config](this); }); } } /** * Data API implementation */ enableDismissTrigger(Alert, 'close'); /** * jQuery */ defineJQueryPlugin(Alert); /** * -------------------------------------------------------------------------- * Bootstrap button.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$e = 'button'; const DATA_KEY$9 = 'bs.button'; const EVENT_KEY$a = `.${DATA_KEY$9}`; const DATA_API_KEY$6 = '.data-api'; const CLASS_NAME_ACTIVE$3 = 'active'; const SELECTOR_DATA_TOGGLE$5 = '[data-bs-toggle="button"]'; const EVENT_CLICK_DATA_API$6 = `click${EVENT_KEY$a}${DATA_API_KEY$6}`; /** * Class definition */ class Button extends BaseComponent { // Getters static get NAME() { return NAME$e; } // Public toggle() { // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE$3)); } // Static static jQueryInterface(config) { return this.each(function () { const data = Button.getOrCreateInstance(this); if (config === 'toggle') { data[config](); } }); } } /** * Data API implementation */ EventHandler.on(document, EVENT_CLICK_DATA_API$6, SELECTOR_DATA_TOGGLE$5, event => { event.preventDefault(); const button = event.target.closest(SELECTOR_DATA_TOGGLE$5); const data = Button.getOrCreateInstance(button); data.toggle(); }); /** * jQuery */ defineJQueryPlugin(Button); /** * -------------------------------------------------------------------------- * Bootstrap util/swipe.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$d = 'swipe'; const EVENT_KEY$9 = '.bs.swipe'; const EVENT_TOUCHSTART = `touchstart${EVENT_KEY$9}`; const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY$9}`; const EVENT_TOUCHEND = `touchend${EVENT_KEY$9}`; const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY$9}`; const EVENT_POINTERUP = `pointerup${EVENT_KEY$9}`; const POINTER_TYPE_TOUCH = 'touch'; const POINTER_TYPE_PEN = 'pen'; const CLASS_NAME_POINTER_EVENT = 'pointer-event'; const SWIPE_THRESHOLD = 40; const Default$c = { endCallback: null, leftCallback: null, rightCallback: null }; const DefaultType$c = { endCallback: '(function|null)', leftCallback: '(function|null)', rightCallback: '(function|null)' }; /** * Class definition */ class Swipe extends Config { constructor(element, config) { super(); this._element = element; if (!element || !Swipe.isSupported()) { return; } this._config = this._getConfig(config); this._deltaX = 0; this._supportPointerEvents = Boolean(window.PointerEvent); this._initEvents(); } // Getters static get Default() { return Default$c; } static get DefaultType() { return DefaultType$c; } static get NAME() { return NAME$d; } // Public dispose() { EventHandler.off(this._element, EVENT_KEY$9); } // Private _start(event) { if (!this._supportPointerEvents) { this._deltaX = event.touches[0].clientX; return; } if (this._eventIsPointerPenTouch(event)) { this._deltaX = event.clientX; } } _end(event) { if (this._eventIsPointerPenTouch(event)) { this._deltaX = event.clientX - this._deltaX; } this._handleSwipe(); execute(this._config.endCallback); } _move(event) { this._deltaX = event.touches && event.touches.length > 1 ? 0 : event.touches[0].clientX - this._deltaX; } _handleSwipe() { const absDeltaX = Math.abs(this._deltaX); if (absDeltaX <= SWIPE_THRESHOLD) { return; } const direction = absDeltaX / this._deltaX; this._deltaX = 0; if (!direction) { return; } execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback); } _initEvents() { if (this._supportPointerEvents) { EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event)); EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event)); this._element.classList.add(CLASS_NAME_POINTER_EVENT); } else { EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event)); EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event)); EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event)); } } _eventIsPointerPenTouch(event) { return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH); } // Static static isSupported() { return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0; } } /** * -------------------------------------------------------------------------- * Bootstrap carousel.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$c = 'carousel'; const DATA_KEY$8 = 'bs.carousel'; const EVENT_KEY$8 = `.${DATA_KEY$8}`; const DATA_API_KEY$5 = '.data-api'; const ARROW_LEFT_KEY$1 = 'ArrowLeft'; const ARROW_RIGHT_KEY$1 = 'ArrowRight'; const TOUCHEVENT_COMPAT_WAIT = 500; // Time for mouse compat events to fire after touch const ORDER_NEXT = 'next'; const ORDER_PREV = 'prev'; const DIRECTION_LEFT = 'left'; const DIRECTION_RIGHT = 'right'; const EVENT_SLIDE = `slide${EVENT_KEY$8}`; const EVENT_SLID = `slid${EVENT_KEY$8}`; const EVENT_KEYDOWN$1 = `keydown${EVENT_KEY$8}`; const EVENT_MOUSEENTER$1 = `mouseenter${EVENT_KEY$8}`; const EVENT_MOUSELEAVE$1 = `mouseleave${EVENT_KEY$8}`; const EVENT_DRAG_START = `dragstart${EVENT_KEY$8}`; const EVENT_LOAD_DATA_API$3 = `load${EVENT_KEY$8}${DATA_API_KEY$5}`; const EVENT_CLICK_DATA_API$5 = `click${EVENT_KEY$8}${DATA_API_KEY$5}`; const CLASS_NAME_CAROUSEL = 'carousel'; const CLASS_NAME_ACTIVE$2 = 'active'; const CLASS_NAME_SLIDE = 'slide'; const CLASS_NAME_END = 'carousel-item-end'; const CLASS_NAME_START = 'carousel-item-start'; const CLASS_NAME_NEXT = 'carousel-item-next'; const CLASS_NAME_PREV = 'carousel-item-prev'; const SELECTOR_ACTIVE = '.active'; const SELECTOR_ITEM = '.carousel-item'; const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM; const SELECTOR_ITEM_IMG = '.carousel-item img'; const SELECTOR_INDICATORS = '.carousel-indicators'; const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'; const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'; const KEY_TO_DIRECTION = { [ARROW_LEFT_KEY$1]: DIRECTION_RIGHT, [ARROW_RIGHT_KEY$1]: DIRECTION_LEFT }; const Default$b = { interval: 5000, keyboard: true, pause: 'hover', ride: false, touch: true, wrap: true }; const DefaultType$b = { interval: '(number|boolean)', // TODO:v6 remove boolean support keyboard: 'boolean', pause: '(string|boolean)', ride: '(boolean|string)', touch: 'boolean', wrap: 'boolean' }; /** * Class definition */ class Carousel extends BaseComponent { constructor(element, config) { super(element, config); this._interval = null; this._activeElement = null; this._isSliding = false; this.touchTimeout = null; this._swipeHelper = null; this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element); this._addEventListeners(); if (this._config.ride === CLASS_NAME_CAROUSEL) { this.cycle(); } } // Getters static get Default() { return Default$b; } static get DefaultType() { return DefaultType$b; } static get NAME() { return NAME$c; } // Public next() { this._slide(ORDER_NEXT); } nextWhenVisible() { // FIXME TODO use `document.visibilityState` // Don't call next when the page isn't visible // or the carousel or its parent isn't visible if (!document.hidden && isVisible(this._element)) { this.next(); } } prev() { this._slide(ORDER_PREV); } pause() { if (this._isSliding) { triggerTransitionEnd(this._element); } this._clearInterval(); } cycle() { this._clearInterval(); this._updateInterval(); this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval); } _maybeEnableCycle() { if (!this._config.ride) { return; } if (this._isSliding) { EventHandler.one(this._element, EVENT_SLID, () => this.cycle()); return; } this.cycle(); } to(index) { const items = this._getItems(); if (index > items.length - 1 || index < 0) { return; } if (this._isSliding) { EventHandler.one(this._element, EVENT_SLID, () => this.to(index)); return; } const activeIndex = this._getItemIndex(this._getActive()); if (activeIndex === index) { return; } const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV; this._slide(order, items[index]); } dispose() { if (this._swipeHelper) { this._swipeHelper.dispose(); } super.dispose(); } // Private _configAfterMerge(config) { config.defaultInterval = config.interval; return config; } _addEventListeners() { if (this._config.keyboard) { EventHandler.on(this._element, EVENT_KEYDOWN$1, event => this._keydown(event)); } if (this._config.pause === 'hover') { EventHandler.on(this._element, EVENT_MOUSEENTER$1, () => this.pause()); EventHandler.on(this._element, EVENT_MOUSELEAVE$1, () => this._maybeEnableCycle()); } if (this._config.touch && Swipe.isSupported()) { this._addTouchEventListeners(); } } _addTouchEventListeners() { for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) { EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault()); } const endCallBack = () => { if (this._config.pause !== 'hover') { return; } // If it's a touch-enabled device, mouseenter/leave are fired as // part of the mouse compatibility events on first tap - the carousel // would stop cycling until user tapped out of it; // here, we listen for touchend, explicitly pause the carousel // (as if it's the second time we tap on it, mouseenter compat event // is NOT fired) and after a timeout (to allow for mouse compatibility // events to fire) we explicitly restart cycling this.pause(); if (this.touchTimeout) { clearTimeout(this.touchTimeout); } this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval); }; const swipeConfig = { leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)), rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)), endCallback: endCallBack }; this._swipeHelper = new Swipe(this._element, swipeConfig); } _keydown(event) { if (/input|textarea/i.test(event.target.tagName)) { return; } const direction = KEY_TO_DIRECTION[event.key]; if (direction) { event.preventDefault(); this._slide(this._directionToOrder(direction)); } } _getItemIndex(element) { return this._getItems().indexOf(element); } _setActiveIndicatorElement(index) { if (!this._indicatorsElement) { return; } const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement); activeIndicator.classList.remove(CLASS_NAME_ACTIVE$2); activeIndicator.removeAttribute('aria-current'); const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement); if (newActiveIndicator) { newActiveIndicator.classList.add(CLASS_NAME_ACTIVE$2); newActiveIndicator.setAttribute('aria-current', 'true'); } } _updateInterval() { const element = this._activeElement || this._getActive(); if (!element) { return; } const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10); this._config.interval = elementInterval || this._config.defaultInterval; } _slide(order, element = null) { if (this._isSliding) { return; } const activeElement = this._getActive(); const isNext = order === ORDER_NEXT; const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap); if (nextElement === activeElement) { return; } const nextElementIndex = this._getItemIndex(nextElement); const triggerEvent = eventName => { return EventHandler.trigger(this._element, eventName, { relatedTarget: nextElement, direction: this._orderToDirection(order), from: this._getItemIndex(activeElement), to: nextElementIndex }); }; const slideEvent = triggerEvent(EVENT_SLIDE); if (slideEvent.defaultPrevented) { return; } if (!activeElement || !nextElement) { // Some weirdness is happening, so we bail // TODO: change tests that use empty divs to avoid this check return; } const isCycling = Boolean(this._interval); this.pause(); this._isSliding = true; this._setActiveIndicatorElement(nextElementIndex); this._activeElement = nextElement; const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END; const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV; nextElement.classList.add(orderClassName); reflow(nextElement); activeElement.classList.add(directionalClassName); nextElement.classList.add(directionalClassName); const completeCallBack = () => { nextElement.classList.remove(directionalClassName, orderClassName); nextElement.classList.add(CLASS_NAME_ACTIVE$2); activeElement.classList.remove(CLASS_NAME_ACTIVE$2, orderClassName, directionalClassName); this._isSliding = false; triggerEvent(EVENT_SLID); }; this._queueCallback(completeCallBack, activeElement, this._isAnimated()); if (isCycling) { this.cycle(); } } _isAnimated() { return this._element.classList.contains(CLASS_NAME_SLIDE); } _getActive() { return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element); } _getItems() { return SelectorEngine.find(SELECTOR_ITEM, this._element); } _clearInterval() { if (this._interval) { clearInterval(this._interval); this._interval = null; } } _directionToOrder(direction) { if (isRTL()) { return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT; } return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV; } _orderToDirection(order) { if (isRTL()) { return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT; } return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT; } // Static static jQueryInterface(config) { return this.each(function () { const data = Carousel.getOrCreateInstance(this, config); if (typeof config === 'number') { data.to(config); return; } if (typeof config === 'string') { if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { throw new TypeError(`No method named "${config}"`); } data[config](); } }); } } /** * Data API implementation */ EventHandler.on(document, EVENT_CLICK_DATA_API$5, SELECTOR_DATA_SLIDE, function (event) { const target = SelectorEngine.getElementFromSelector(this); if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) { return; } event.preventDefault(); const carousel = Carousel.getOrCreateInstance(target); const slideIndex = this.getAttribute('data-bs-slide-to'); if (slideIndex) { carousel.to(slideIndex); carousel._maybeEnableCycle(); return; } if (Manipulator.getDataAttribute(this, 'slide') === 'next') { carousel.next(); carousel._maybeEnableCycle(); return; } carousel.prev(); carousel._maybeEnableCycle(); }); EventHandler.on(window, EVENT_LOAD_DATA_API$3, () => { const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE); for (const carousel of carousels) { Carousel.getOrCreateInstance(carousel); } }); /** * jQuery */ defineJQueryPlugin(Carousel); /** * -------------------------------------------------------------------------- * Bootstrap collapse.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$b = 'collapse'; const DATA_KEY$7 = 'bs.collapse'; const EVENT_KEY$7 = `.${DATA_KEY$7}`; const DATA_API_KEY$4 = '.data-api'; const EVENT_SHOW$6 = `show${EVENT_KEY$7}`; const EVENT_SHOWN$6 = `shown${EVENT_KEY$7}`; const EVENT_HIDE$6 = `hide${EVENT_KEY$7}`; const EVENT_HIDDEN$6 = `hidden${EVENT_KEY$7}`; const EVENT_CLICK_DATA_API$4 = `click${EVENT_KEY$7}${DATA_API_KEY$4}`; const CLASS_NAME_SHOW$7 = 'show'; const CLASS_NAME_COLLAPSE = 'collapse'; const CLASS_NAME_COLLAPSING = 'collapsing'; const CLASS_NAME_COLLAPSED = 'collapsed'; const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`; const CLASS_NAME_HORIZONTAL = 'collapse-horizontal'; const WIDTH = 'width'; const HEIGHT = 'height'; const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'; const SELECTOR_DATA_TOGGLE$4 = '[data-bs-toggle="collapse"]'; const Default$a = { parent: null, toggle: true }; const DefaultType$a = { parent: '(null|element)', toggle: 'boolean' }; /** * Class definition */ class Collapse extends BaseComponent { constructor(element, config) { super(element, config); this._isTransitioning = false; this._triggerArray = []; const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE$4); for (const elem of toggleList) { const selector = SelectorEngine.getSelectorFromElement(elem); const filterElement = SelectorEngine.find(selector).filter(foundElement => foundElement === this._element); if (selector !== null && filterElement.length) { this._triggerArray.push(elem); } } this._initializeChildren(); if (!this._config.parent) { this._addAriaAndCollapsedClass(this._triggerArray, this._isShown()); } if (this._config.toggle) { this.toggle(); } } // Getters static get Default() { return Default$a; } static get DefaultType() { return DefaultType$a; } static get NAME() { return NAME$b; } // Public toggle() { if (this._isShown()) { this.hide(); } else { this.show(); } } show() { if (this._isTransitioning || this._isShown()) { return; } let activeChildren = []; // find active children if (this._config.parent) { activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES).filter(element => element !== this._element).map(element => Collapse.getOrCreateInstance(element, { toggle: false })); } if (activeChildren.length && activeChildren[0]._isTransitioning) { return; } const startEvent = EventHandler.trigger(this._element, EVENT_SHOW$6); if (startEvent.defaultPrevented) { return; } for (const activeInstance of activeChildren) { activeInstance.hide(); } const dimension = this._getDimension(); this._element.classList.remove(CLASS_NAME_COLLAPSE); this._element.classList.add(CLASS_NAME_COLLAPSING); this._element.style[dimension] = 0; this._addAriaAndCollapsedClass(this._triggerArray, true); this._isTransitioning = true; const complete = () => { this._isTransitioning = false; this._element.classList.remove(CLASS_NAME_COLLAPSING); this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW$7); this._element.style[dimension] = ''; EventHandler.trigger(this._element, EVENT_SHOWN$6); }; const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1); const scrollSize = `scroll${capitalizedDimension}`; this._queueCallback(complete, this._element, true); this._element.style[dimension] = `${this._element[scrollSize]}px`; } hide() { if (this._isTransitioning || !this._isShown()) { return; } const startEvent = EventHandler.trigger(this._element, EVENT_HIDE$6); if (startEvent.defaultPrevented) { return; } const dimension = this._getDimension(); this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`; reflow(this._element); this._element.classList.add(CLASS_NAME_COLLAPSING); this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW$7); for (const trigger of this._triggerArray) { const element = SelectorEngine.getElementFromSelector(trigger); if (element && !this._isShown(element)) { this._addAriaAndCollapsedClass([trigger], false); } } this._isTransitioning = true; const complete = () => { this._isTransitioning = false; this._element.classList.remove(CLASS_NAME_COLLAPSING); this._element.classList.add(CLASS_NAME_COLLAPSE); EventHandler.trigger(this._element, EVENT_HIDDEN$6); }; this._element.style[dimension] = ''; this._queueCallback(complete, this._element, true); } // Private _isShown(element = this._element) { return element.classList.contains(CLASS_NAME_SHOW$7); } _configAfterMerge(config) { config.toggle = Boolean(config.toggle); // Coerce string values config.parent = getElement(config.parent); return config; } _getDimension() { return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT; } _initializeChildren() { if (!this._config.parent) { return; } const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE$4); for (const element of children) { const selected = SelectorEngine.getElementFromSelector(element); if (selected) { this._addAriaAndCollapsedClass([element], this._isShown(selected)); } } } _getFirstLevelChildren(selector) { const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent); // remove children if greater depth return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element)); } _addAriaAndCollapsedClass(triggerArray, isOpen) { if (!triggerArray.length) { return; } for (const element of triggerArray) { element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen); element.setAttribute('aria-expanded', isOpen); } } // Static static jQueryInterface(config) { const _config = {}; if (typeof config === 'string' && /show|hide/.test(config)) { _config.toggle = false; } return this.each(function () { const data = Collapse.getOrCreateInstance(this, _config); if (typeof config === 'string') { if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`); } data[config](); } }); } } /** * Data API implementation */ EventHandler.on(document, EVENT_CLICK_DATA_API$4, SELECTOR_DATA_TOGGLE$4, function (event) { // preventDefault only for
elements (which change the URL) not inside the collapsible element if (event.target.tagName === 'A' || event.delegateTarget && event.delegateTarget.tagName === 'A') { event.preventDefault(); } for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) { Collapse.getOrCreateInstance(element, { toggle: false }).toggle(); } }); /** * jQuery */ defineJQueryPlugin(Collapse); var top = 'top'; var bottom = 'bottom'; var right = 'right'; var left = 'left'; var auto = 'auto'; var basePlacements = [top, bottom, right, left]; var start = 'start'; var end = 'end'; var clippingParents = 'clippingParents'; var viewport = 'viewport'; var popper = 'popper'; var reference = 'reference'; var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) { return acc.concat([placement + "-" + start, placement + "-" + end]); }, []); var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) { return acc.concat([placement, placement + "-" + start, placement + "-" + end]); }, []); // modifiers that need to read the DOM var beforeRead = 'beforeRead'; var read = 'read'; var afterRead = 'afterRead'; // pure-logic modifiers var beforeMain = 'beforeMain'; var main = 'main'; var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state) var beforeWrite = 'beforeWrite'; var write = 'write'; var afterWrite = 'afterWrite'; var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite]; function getNodeName(element) { return element ? (element.nodeName || '').toLowerCase() : null; } function getWindow(node) { if (node == null) { return window; } if (node.toString() !== '[object Window]') { var ownerDocument = node.ownerDocument; return ownerDocument ? ownerDocument.defaultView || window : window; } return node; } function isElement(node) { var OwnElement = getWindow(node).Element; return node instanceof OwnElement || node instanceof Element; } function isHTMLElement(node) { var OwnElement = getWindow(node).HTMLElement; return node instanceof OwnElement || node instanceof HTMLElement; } function isShadowRoot(node) { // IE 11 has no ShadowRoot if (typeof ShadowRoot === 'undefined') { return false; } var OwnElement = getWindow(node).ShadowRoot; return node instanceof OwnElement || node instanceof ShadowRoot; } // and applies them to the HTMLElements such as popper and arrow function applyStyles(_ref) { var state = _ref.state; Object.keys(state.elements).forEach(function (name) { var style = state.styles[name] || {}; var attributes = state.attributes[name] || {}; var element = state.elements[name]; // arrow is optional + virtual elements if (!isHTMLElement(element) || !getNodeName(element)) { return; } // Flow doesn't support to extend this property, but it's the most // effective way to apply styles to an HTMLElement // $FlowFixMe[cannot-write] Object.assign(element.style, style); Object.keys(attributes).forEach(function (name) { var value = attributes[name]; if (value === false) { element.removeAttribute(name); } else { element.setAttribute(name, value === true ? '' : value); } }); }); } function effect$2(_ref2) { var state = _ref2.state; var initialStyles = { popper: { position: state.options.strategy, left: '0', top: '0', margin: '0' }, arrow: { position: 'absolute' }, reference: {} }; Object.assign(state.elements.popper.style, initialStyles.popper); state.styles = initialStyles; if (state.elements.arrow) { Object.assign(state.elements.arrow.style, initialStyles.arrow); } return function () { Object.keys(state.elements).forEach(function (name) { var element = state.elements[name]; var attributes = state.attributes[name] || {}; var styleProperties = Object.keys(state.styles.hasOwnProperty(name) ? state.styles[name] : initialStyles[name]); // Set all values to an empty string to unset them var style = styleProperties.reduce(function (style, property) { style[property] = ''; return style; }, {}); // arrow is optional + virtual elements if (!isHTMLElement(element) || !getNodeName(element)) { return; } Object.assign(element.style, style); Object.keys(attributes).forEach(function (attribute) { element.removeAttribute(attribute); }); }); }; } // eslint-disable-next-line import/no-unused-modules const applyStyles$1 = { name: 'applyStyles', enabled: true, phase: 'write', fn: applyStyles, effect: effect$2, requires: ['computeStyles'] }; function getBasePlacement(placement) { return placement.split('-')[0]; } var max = Math.max; var min = Math.min; var round = Math.round; function getUAString() { var uaData = navigator.userAgentData; if (uaData != null && uaData.brands && Array.isArray(uaData.brands)) { return uaData.brands.map(function (item) { return item.brand + "/" + item.version; }).join(' '); } return navigator.userAgent; } function isLayoutViewport() { return !/^((?!chrome|android).)*safari/i.test(getUAString()); } function getBoundingClientRect(element, includeScale, isFixedStrategy) { if (includeScale === void 0) { includeScale = false; } if (isFixedStrategy === void 0) { isFixedStrategy = false; } var clientRect = element.getBoundingClientRect(); var scaleX = 1; var scaleY = 1; if (includeScale && isHTMLElement(element)) { scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1; scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1; } var _ref = isElement(element) ? getWindow(element) : window, visualViewport = _ref.visualViewport; var addVisualOffsets = !isLayoutViewport() && isFixedStrategy; var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX; var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY; var width = clientRect.width / scaleX; var height = clientRect.height / scaleY; return { width: width, height: height, top: y, right: x + width, bottom: y + height, left: x, x: x, y: y }; } // means it doesn't take into account transforms. function getLayoutRect(element) { var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed. // Fixes https://github.com/popperjs/popper-core/issues/1223 var width = element.offsetWidth; var height = element.offsetHeight; if (Math.abs(clientRect.width - width) <= 1) { width = clientRect.width; } if (Math.abs(clientRect.height - height) <= 1) { height = clientRect.height; } return { x: element.offsetLeft, y: element.offsetTop, width: width, height: height }; } function contains(parent, child) { var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method if (parent.contains(child)) { return true; } // then fallback to custom implementation with Shadow DOM support else if (rootNode && isShadowRoot(rootNode)) { var next = child; do { if (next && parent.isSameNode(next)) { return true; } // $FlowFixMe[prop-missing]: need a better way to handle this... next = next.parentNode || next.host; } while (next); } // Give up, the result is false return false; } function getComputedStyle$1(element) { return getWindow(element).getComputedStyle(element); } function isTableElement(element) { return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0; } function getDocumentElement(element) { // $FlowFixMe[incompatible-return]: assume body is always available return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing] element.document) || window.document).documentElement; } function getParentNode(element) { if (getNodeName(element) === 'html') { return element; } return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle // $FlowFixMe[incompatible-return] // $FlowFixMe[prop-missing] element.assignedSlot || // step into the shadow DOM of the parent of a slotted node element.parentNode || ( // DOM Element detected isShadowRoot(element) ? element.host : null) || // ShadowRoot detected // $FlowFixMe[incompatible-call]: HTMLElement is a Node getDocumentElement(element) // fallback ); } function getTrueOffsetParent(element) { if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837 getComputedStyle$1(element).position === 'fixed') { return null; } return element.offsetParent; } // `.offsetParent` reports `null` for fixed elements, while absolute elements // return the containing block function getContainingBlock(element) { var isFirefox = /firefox/i.test(getUAString()); var isIE = /Trident/i.test(getUAString()); if (isIE && isHTMLElement(element)) { // In IE 9, 10 and 11 fixed elements containing block is always established by the viewport var elementCss = getComputedStyle$1(element); if (elementCss.position === 'fixed') { return null; } } var currentNode = getParentNode(element); if (isShadowRoot(currentNode)) { currentNode = currentNode.host; } while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) { var css = getComputedStyle$1(currentNode); // This is non-exhaustive but covers the most common CSS properties that // create a containing block. // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') { return currentNode; } else { currentNode = currentNode.parentNode; } } return null; } // Gets the closest ancestor positioned element. Handles some edge cases, // such as table ancestors and cross browser bugs. function getOffsetParent(element) { var window = getWindow(element); var offsetParent = getTrueOffsetParent(element); while (offsetParent && isTableElement(offsetParent) && getComputedStyle$1(offsetParent).position === 'static') { offsetParent = getTrueOffsetParent(offsetParent); } if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle$1(offsetParent).position === 'static')) { return window; } return offsetParent || getContainingBlock(element) || window; } function getMainAxisFromPlacement(placement) { return ['top', 'bottom'].indexOf(placement) >= 0 ? 'x' : 'y'; } function within(min$1, value, max$1) { return max(min$1, min(value, max$1)); } function withinMaxClamp(min, value, max) { var v = within(min, value, max); return v > max ? max : v; } function getFreshSideObject() { return { top: 0, right: 0, bottom: 0, left: 0 }; } function mergePaddingObject(paddingObject) { return Object.assign({}, getFreshSideObject(), paddingObject); } function expandToHashMap(value, keys) { return keys.reduce(function (hashMap, key) { hashMap[key] = value; return hashMap; }, {}); } var toPaddingObject = function toPaddingObject(padding, state) { padding = typeof padding === 'function' ? padding(Object.assign({}, state.rects, { placement: state.placement })) : padding; return mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); }; function arrow(_ref) { var _state$modifiersData$; var state = _ref.state, name = _ref.name, options = _ref.options; var arrowElement = state.elements.arrow; var popperOffsets = state.modifiersData.popperOffsets; var basePlacement = getBasePlacement(state.placement); var axis = getMainAxisFromPlacement(basePlacement); var isVertical = [left, right].indexOf(basePlacement) >= 0; var len = isVertical ? 'height' : 'width'; if (!arrowElement || !popperOffsets) { return; } var paddingObject = toPaddingObject(options.padding, state); var arrowRect = getLayoutRect(arrowElement); var minProp = axis === 'y' ? top : left; var maxProp = axis === 'y' ? bottom : right; var endDiff = state.rects.reference[len] + state.rects.reference[axis] - popperOffsets[axis] - state.rects.popper[len]; var startDiff = popperOffsets[axis] - state.rects.reference[axis]; var arrowOffsetParent = getOffsetParent(arrowElement); var clientSize = arrowOffsetParent ? axis === 'y' ? arrowOffsetParent.clientHeight || 0 : arrowOffsetParent.clientWidth || 0 : 0; var centerToReference = endDiff / 2 - startDiff / 2; // Make sure the arrow doesn't overflow the popper if the center point is // outside of the popper bounds var min = paddingObject[minProp]; var max = clientSize - arrowRect[len] - paddingObject[maxProp]; var center = clientSize / 2 - arrowRect[len] / 2 + centerToReference; var offset = within(min, center, max); // Prevents breaking syntax highlighting... var axisProp = axis; state.modifiersData[name] = (_state$modifiersData$ = {}, _state$modifiersData$[axisProp] = offset, _state$modifiersData$.centerOffset = offset - center, _state$modifiersData$); } function effect$1(_ref2) { var state = _ref2.state, options = _ref2.options; var _options$element = options.element, arrowElement = _options$element === void 0 ? '[data-popper-arrow]' : _options$element; if (arrowElement == null) { return; } // CSS selector if (typeof arrowElement === 'string') { arrowElement = state.elements.popper.querySelector(arrowElement); if (!arrowElement) { return; } } if (!contains(state.elements.popper, arrowElement)) { return; } state.elements.arrow = arrowElement; } // eslint-disable-next-line import/no-unused-modules const arrow$1 = { name: 'arrow', enabled: true, phase: 'main', fn: arrow, effect: effect$1, requires: ['popperOffsets'], requiresIfExists: ['preventOverflow'] }; function getVariation(placement) { return placement.split('-')[1]; } var unsetSides = { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' }; // Round the offsets to the nearest suitable subpixel based on the DPR. // Zooming can change the DPR, but it seems to report a value that will // cleanly divide the values into the appropriate subpixels. function roundOffsetsByDPR(_ref, win) { var x = _ref.x, y = _ref.y; var dpr = win.devicePixelRatio || 1; return { x: round(x * dpr) / dpr || 0, y: round(y * dpr) / dpr || 0 }; } function mapToStyles(_ref2) { var _Object$assign2; var popper = _ref2.popper, popperRect = _ref2.popperRect, placement = _ref2.placement, variation = _ref2.variation, offsets = _ref2.offsets, position = _ref2.position, gpuAcceleration = _ref2.gpuAcceleration, adaptive = _ref2.adaptive, roundOffsets = _ref2.roundOffsets, isFixed = _ref2.isFixed; var _offsets$x = offsets.x, x = _offsets$x === void 0 ? 0 : _offsets$x, _offsets$y = offsets.y, y = _offsets$y === void 0 ? 0 : _offsets$y; var _ref3 = typeof roundOffsets === 'function' ? roundOffsets({ x: x, y: y }) : { x: x, y: y }; x = _ref3.x; y = _ref3.y; var hasX = offsets.hasOwnProperty('x'); var hasY = offsets.hasOwnProperty('y'); var sideX = left; var sideY = top; var win = window; if (adaptive) { var offsetParent = getOffsetParent(popper); var heightProp = 'clientHeight'; var widthProp = 'clientWidth'; if (offsetParent === getWindow(popper)) { offsetParent = getDocumentElement(popper); if (getComputedStyle$1(offsetParent).position !== 'static' && position === 'absolute') { heightProp = 'scrollHeight'; widthProp = 'scrollWidth'; } } // $FlowFixMe[incompatible-cast]: force type refinement, we compare offsetParent with window above, but Flow doesn't detect it offsetParent = offsetParent; if (placement === top || (placement === left || placement === right) && variation === end) { sideY = bottom; var offsetY = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.height : // $FlowFixMe[prop-missing] offsetParent[heightProp]; y -= offsetY - popperRect.height; y *= gpuAcceleration ? 1 : -1; } if (placement === left || (placement === top || placement === bottom) && variation === end) { sideX = right; var offsetX = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.width : // $FlowFixMe[prop-missing] offsetParent[widthProp]; x -= offsetX - popperRect.width; x *= gpuAcceleration ? 1 : -1; } } var commonStyles = Object.assign({ position: position }, adaptive && unsetSides); var _ref4 = roundOffsets === true ? roundOffsetsByDPR({ x: x, y: y }, getWindow(popper)) : { x: x, y: y }; x = _ref4.x; y = _ref4.y; if (gpuAcceleration) { var _Object$assign; return Object.assign({}, commonStyles, (_Object$assign = {}, _Object$assign[sideY] = hasY ? '0' : '', _Object$assign[sideX] = hasX ? '0' : '', _Object$assign.transform = (win.devicePixelRatio || 1) <= 1 ? "translate(" + x + "px, " + y + "px)" : "translate3d(" + x + "px, " + y + "px, 0)", _Object$assign)); } return Object.assign({}, commonStyles, (_Object$assign2 = {}, _Object$assign2[sideY] = hasY ? y + "px" : '', _Object$assign2[sideX] = hasX ? x + "px" : '', _Object$assign2.transform = '', _Object$assign2)); } function computeStyles(_ref5) { var state = _ref5.state, options = _ref5.options; var _options$gpuAccelerat = options.gpuAcceleration, gpuAcceleration = _options$gpuAccelerat === void 0 ? true : _options$gpuAccelerat, _options$adaptive = options.adaptive, adaptive = _options$adaptive === void 0 ? true : _options$adaptive, _options$roundOffsets = options.roundOffsets, roundOffsets = _options$roundOffsets === void 0 ? true : _options$roundOffsets; var commonStyles = { placement: getBasePlacement(state.placement), variation: getVariation(state.placement), popper: state.elements.popper, popperRect: state.rects.popper, gpuAcceleration: gpuAcceleration, isFixed: state.options.strategy === 'fixed' }; if (state.modifiersData.popperOffsets != null) { state.styles.popper = Object.assign({}, state.styles.popper, mapToStyles(Object.assign({}, commonStyles, { offsets: state.modifiersData.popperOffsets, position: state.options.strategy, adaptive: adaptive, roundOffsets: roundOffsets }))); } if (state.modifiersData.arrow != null) { state.styles.arrow = Object.assign({}, state.styles.arrow, mapToStyles(Object.assign({}, commonStyles, { offsets: state.modifiersData.arrow, position: 'absolute', adaptive: false, roundOffsets: roundOffsets }))); } state.attributes.popper = Object.assign({}, state.attributes.popper, { 'data-popper-placement': state.placement }); } // eslint-disable-next-line import/no-unused-modules const computeStyles$1 = { name: 'computeStyles', enabled: true, phase: 'beforeWrite', fn: computeStyles, data: {} }; var passive = { passive: true }; function effect(_ref) { var state = _ref.state, instance = _ref.instance, options = _ref.options; var _options$scroll = options.scroll, scroll = _options$scroll === void 0 ? true : _options$scroll, _options$resize = options.resize, resize = _options$resize === void 0 ? true : _options$resize; var window = getWindow(state.elements.popper); var scrollParents = [].concat(state.scrollParents.reference, state.scrollParents.popper); if (scroll) { scrollParents.forEach(function (scrollParent) { scrollParent.addEventListener('scroll', instance.update, passive); }); } if (resize) { window.addEventListener('resize', instance.update, passive); } return function () { if (scroll) { scrollParents.forEach(function (scrollParent) { scrollParent.removeEventListener('scroll', instance.update, passive); }); } if (resize) { window.removeEventListener('resize', instance.update, passive); } }; } // eslint-disable-next-line import/no-unused-modules const eventListeners = { name: 'eventListeners', enabled: true, phase: 'write', fn: function fn() {}, effect: effect, data: {} }; var hash$1 = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' }; function getOppositePlacement(placement) { return placement.replace(/left|right|bottom|top/g, function (matched) { return hash$1[matched]; }); } var hash = { start: 'end', end: 'start' }; function getOppositeVariationPlacement(placement) { return placement.replace(/start|end/g, function (matched) { return hash[matched]; }); } function getWindowScroll(node) { var win = getWindow(node); var scrollLeft = win.pageXOffset; var scrollTop = win.pageYOffset; return { scrollLeft: scrollLeft, scrollTop: scrollTop }; } function getWindowScrollBarX(element) { // If has a CSS width greater than the viewport, then this will be // incorrect for RTL. // Popper 1 is broken in this case and never had a bug report so let's assume // it's not an issue. I don't think anyone ever specifies width on // anyway. // Browsers where the left scrollbar doesn't cause an issue report `0` for // this (e.g. Edge 2019, IE11, Safari) return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft; } function getViewportRect(element, strategy) { var win = getWindow(element); var html = getDocumentElement(element); var visualViewport = win.visualViewport; var width = html.clientWidth; var height = html.clientHeight; var x = 0; var y = 0; if (visualViewport) { width = visualViewport.width; height = visualViewport.height; var layoutViewport = isLayoutViewport(); if (layoutViewport || !layoutViewport && strategy === 'fixed') { x = visualViewport.offsetLeft; y = visualViewport.offsetTop; } } return { width: width, height: height, x: x + getWindowScrollBarX(element), y: y }; } // of the `` and `` rect bounds if horizontally scrollable function getDocumentRect(element) { var _element$ownerDocumen; var html = getDocumentElement(element); var winScroll = getWindowScroll(element); var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body; var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0); var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0); var x = -winScroll.scrollLeft + getWindowScrollBarX(element); var y = -winScroll.scrollTop; if (getComputedStyle$1(body || html).direction === 'rtl') { x += max(html.clientWidth, body ? body.clientWidth : 0) - width; } return { width: width, height: height, x: x, y: y }; } function isScrollParent(element) { // Firefox wants us to check `-x` and `-y` variations as well var _getComputedStyle = getComputedStyle$1(element), overflow = _getComputedStyle.overflow, overflowX = _getComputedStyle.overflowX, overflowY = _getComputedStyle.overflowY; return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX); } function getScrollParent(node) { if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) { // $FlowFixMe[incompatible-return]: assume body is always available return node.ownerDocument.body; } if (isHTMLElement(node) && isScrollParent(node)) { return node; } return getScrollParent(getParentNode(node)); } /* given a DOM element, return the list of all scroll parents, up the list of ancesors until we get to the top window object. This list is what we attach scroll listeners to, because if any of these parent elements scroll, we'll need to re-calculate the reference element's position. */ function listScrollParents(element, list) { var _element$ownerDocumen; if (list === void 0) { list = []; } var scrollParent = getScrollParent(element); var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body); var win = getWindow(scrollParent); var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent; var updatedList = list.concat(target); return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here updatedList.concat(listScrollParents(getParentNode(target))); } function rectToClientRect(rect) { return Object.assign({}, rect, { left: rect.x, top: rect.y, right: rect.x + rect.width, bottom: rect.y + rect.height }); } function getInnerBoundingClientRect(element, strategy) { var rect = getBoundingClientRect(element, false, strategy === 'fixed'); rect.top = rect.top + element.clientTop; rect.left = rect.left + element.clientLeft; rect.bottom = rect.top + element.clientHeight; rect.right = rect.left + element.clientWidth; rect.width = element.clientWidth; rect.height = element.clientHeight; rect.x = rect.left; rect.y = rect.top; return rect; } function getClientRectFromMixedType(element, clippingParent, strategy) { return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element))); } // A "clipping parent" is an overflowable container with the characteristic of // clipping (or hiding) overflowing elements with a position different from // `initial` function getClippingParents(element) { var clippingParents = listScrollParents(getParentNode(element)); var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle$1(element).position) >= 0; var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element; if (!isElement(clipperElement)) { return []; } // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414 return clippingParents.filter(function (clippingParent) { return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body'; }); } // Gets the maximum area that the element is visible in due to any number of // clipping parents function getClippingRect(element, boundary, rootBoundary, strategy) { var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary); var clippingParents = [].concat(mainClippingParents, [rootBoundary]); var firstClippingParent = clippingParents[0]; var clippingRect = clippingParents.reduce(function (accRect, clippingParent) { var rect = getClientRectFromMixedType(element, clippingParent, strategy); accRect.top = max(rect.top, accRect.top); accRect.right = min(rect.right, accRect.right); accRect.bottom = min(rect.bottom, accRect.bottom); accRect.left = max(rect.left, accRect.left); return accRect; }, getClientRectFromMixedType(element, firstClippingParent, strategy)); clippingRect.width = clippingRect.right - clippingRect.left; clippingRect.height = clippingRect.bottom - clippingRect.top; clippingRect.x = clippingRect.left; clippingRect.y = clippingRect.top; return clippingRect; } function computeOffsets(_ref) { var reference = _ref.reference, element = _ref.element, placement = _ref.placement; var basePlacement = placement ? getBasePlacement(placement) : null; var variation = placement ? getVariation(placement) : null; var commonX = reference.x + reference.width / 2 - element.width / 2; var commonY = reference.y + reference.height / 2 - element.height / 2; var offsets; switch (basePlacement) { case top: offsets = { x: commonX, y: reference.y - element.height }; break; case bottom: offsets = { x: commonX, y: reference.y + reference.height }; break; case right: offsets = { x: reference.x + reference.width, y: commonY }; break; case left: offsets = { x: reference.x - element.width, y: commonY }; break; default: offsets = { x: reference.x, y: reference.y }; } var mainAxis = basePlacement ? getMainAxisFromPlacement(basePlacement) : null; if (mainAxis != null) { var len = mainAxis === 'y' ? 'height' : 'width'; switch (variation) { case start: offsets[mainAxis] = offsets[mainAxis] - (reference[len] / 2 - element[len] / 2); break; case end: offsets[mainAxis] = offsets[mainAxis] + (reference[len] / 2 - element[len] / 2); break; } } return offsets; } function detectOverflow(state, options) { if (options === void 0) { options = {}; } var _options = options, _options$placement = _options.placement, placement = _options$placement === void 0 ? state.placement : _options$placement, _options$strategy = _options.strategy, strategy = _options$strategy === void 0 ? state.strategy : _options$strategy, _options$boundary = _options.boundary, boundary = _options$boundary === void 0 ? clippingParents : _options$boundary, _options$rootBoundary = _options.rootBoundary, rootBoundary = _options$rootBoundary === void 0 ? viewport : _options$rootBoundary, _options$elementConte = _options.elementContext, elementContext = _options$elementConte === void 0 ? popper : _options$elementConte, _options$altBoundary = _options.altBoundary, altBoundary = _options$altBoundary === void 0 ? false : _options$altBoundary, _options$padding = _options.padding, padding = _options$padding === void 0 ? 0 : _options$padding; var paddingObject = mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); var altContext = elementContext === popper ? reference : popper; var popperRect = state.rects.popper; var element = state.elements[altBoundary ? altContext : elementContext]; var clippingClientRect = getClippingRect(isElement(element) ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, rootBoundary, strategy); var referenceClientRect = getBoundingClientRect(state.elements.reference); var popperOffsets = computeOffsets({ reference: referenceClientRect, element: popperRect, placement: placement }); var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets)); var elementClientRect = elementContext === popper ? popperClientRect : referenceClientRect; // positive = overflowing the clipping rect // 0 or negative = within the clipping rect var overflowOffsets = { top: clippingClientRect.top - elementClientRect.top + paddingObject.top, bottom: elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom, left: clippingClientRect.left - elementClientRect.left + paddingObject.left, right: elementClientRect.right - clippingClientRect.right + paddingObject.right }; var offsetData = state.modifiersData.offset; // Offsets can be applied only to the popper element if (elementContext === popper && offsetData) { var offset = offsetData[placement]; Object.keys(overflowOffsets).forEach(function (key) { var multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1; var axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x'; overflowOffsets[key] += offset[axis] * multiply; }); } return overflowOffsets; } function computeAutoPlacement(state, options) { if (options === void 0) { options = {}; } var _options = options, placement = _options.placement, boundary = _options.boundary, rootBoundary = _options.rootBoundary, padding = _options.padding, flipVariations = _options.flipVariations, _options$allowedAutoP = _options.allowedAutoPlacements, allowedAutoPlacements = _options$allowedAutoP === void 0 ? placements : _options$allowedAutoP; var variation = getVariation(placement); var placements$1 = variation ? flipVariations ? variationPlacements : variationPlacements.filter(function (placement) { return getVariation(placement) === variation; }) : basePlacements; var allowedPlacements = placements$1.filter(function (placement) { return allowedAutoPlacements.indexOf(placement) >= 0; }); if (allowedPlacements.length === 0) { allowedPlacements = placements$1; } // $FlowFixMe[incompatible-type]: Flow seems to have problems with two array unions... var overflows = allowedPlacements.reduce(function (acc, placement) { acc[placement] = detectOverflow(state, { placement: placement, boundary: boundary, rootBoundary: rootBoundary, padding: padding })[getBasePlacement(placement)]; return acc; }, {}); return Object.keys(overflows).sort(function (a, b) { return overflows[a] - overflows[b]; }); } function getExpandedFallbackPlacements(placement) { if (getBasePlacement(placement) === auto) { return []; } var oppositePlacement = getOppositePlacement(placement); return [getOppositeVariationPlacement(placement), oppositePlacement, getOppositeVariationPlacement(oppositePlacement)]; } function flip(_ref) { var state = _ref.state, options = _ref.options, name = _ref.name; if (state.modifiersData[name]._skip) { return; } var _options$mainAxis = options.mainAxis, checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, _options$altAxis = options.altAxis, checkAltAxis = _options$altAxis === void 0 ? true : _options$altAxis, specifiedFallbackPlacements = options.fallbackPlacements, padding = options.padding, boundary = options.boundary, rootBoundary = options.rootBoundary, altBoundary = options.altBoundary, _options$flipVariatio = options.flipVariations, flipVariations = _options$flipVariatio === void 0 ? true : _options$flipVariatio, allowedAutoPlacements = options.allowedAutoPlacements; var preferredPlacement = state.options.placement; var basePlacement = getBasePlacement(preferredPlacement); var isBasePlacement = basePlacement === preferredPlacement; var fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipVariations ? [getOppositePlacement(preferredPlacement)] : getExpandedFallbackPlacements(preferredPlacement)); var placements = [preferredPlacement].concat(fallbackPlacements).reduce(function (acc, placement) { return acc.concat(getBasePlacement(placement) === auto ? computeAutoPlacement(state, { placement: placement, boundary: boundary, rootBoundary: rootBoundary, padding: padding, flipVariations: flipVariations, allowedAutoPlacements: allowedAutoPlacements }) : placement); }, []); var referenceRect = state.rects.reference; var popperRect = state.rects.popper; var checksMap = new Map(); var makeFallbackChecks = true; var firstFittingPlacement = placements[0]; for (var i = 0; i < placements.length; i++) { var placement = placements[i]; var _basePlacement = getBasePlacement(placement); var isStartVariation = getVariation(placement) === start; var isVertical = [top, bottom].indexOf(_basePlacement) >= 0; var len = isVertical ? 'width' : 'height'; var overflow = detectOverflow(state, { placement: placement, boundary: boundary, rootBoundary: rootBoundary, altBoundary: altBoundary, padding: padding }); var mainVariationSide = isVertical ? isStartVariation ? right : left : isStartVariation ? bottom : top; if (referenceRect[len] > popperRect[len]) { mainVariationSide = getOppositePlacement(mainVariationSide); } var altVariationSide = getOppositePlacement(mainVariationSide); var checks = []; if (checkMainAxis) { checks.push(overflow[_basePlacement] <= 0); } if (checkAltAxis) { checks.push(overflow[mainVariationSide] <= 0, overflow[altVariationSide] <= 0); } if (checks.every(function (check) { return check; })) { firstFittingPlacement = placement; makeFallbackChecks = false; break; } checksMap.set(placement, checks); } if (makeFallbackChecks) { // `2` may be desired in some cases – research later var numberOfChecks = flipVariations ? 3 : 1; var _loop = function _loop(_i) { var fittingPlacement = placements.find(function (placement) { var checks = checksMap.get(placement); if (checks) { return checks.slice(0, _i).every(function (check) { return check; }); } }); if (fittingPlacement) { firstFittingPlacement = fittingPlacement; return "break"; } }; for (var _i = numberOfChecks; _i > 0; _i--) { var _ret = _loop(_i); if (_ret === "break") break; } } if (state.placement !== firstFittingPlacement) { state.modifiersData[name]._skip = true; state.placement = firstFittingPlacement; state.reset = true; } } // eslint-disable-next-line import/no-unused-modules const flip$1 = { name: 'flip', enabled: true, phase: 'main', fn: flip, requiresIfExists: ['offset'], data: { _skip: false } }; function getSideOffsets(overflow, rect, preventedOffsets) { if (preventedOffsets === void 0) { preventedOffsets = { x: 0, y: 0 }; } return { top: overflow.top - rect.height - preventedOffsets.y, right: overflow.right - rect.width + preventedOffsets.x, bottom: overflow.bottom - rect.height + preventedOffsets.y, left: overflow.left - rect.width - preventedOffsets.x }; } function isAnySideFullyClipped(overflow) { return [top, right, bottom, left].some(function (side) { return overflow[side] >= 0; }); } function hide(_ref) { var state = _ref.state, name = _ref.name; var referenceRect = state.rects.reference; var popperRect = state.rects.popper; var preventedOffsets = state.modifiersData.preventOverflow; var referenceOverflow = detectOverflow(state, { elementContext: 'reference' }); var popperAltOverflow = detectOverflow(state, { altBoundary: true }); var referenceClippingOffsets = getSideOffsets(referenceOverflow, referenceRect); var popperEscapeOffsets = getSideOffsets(popperAltOverflow, popperRect, preventedOffsets); var isReferenceHidden = isAnySideFullyClipped(referenceClippingOffsets); var hasPopperEscaped = isAnySideFullyClipped(popperEscapeOffsets); state.modifiersData[name] = { referenceClippingOffsets: referenceClippingOffsets, popperEscapeOffsets: popperEscapeOffsets, isReferenceHidden: isReferenceHidden, hasPopperEscaped: hasPopperEscaped }; state.attributes.popper = Object.assign({}, state.attributes.popper, { 'data-popper-reference-hidden': isReferenceHidden, 'data-popper-escaped': hasPopperEscaped }); } // eslint-disable-next-line import/no-unused-modules const hide$1 = { name: 'hide', enabled: true, phase: 'main', requiresIfExists: ['preventOverflow'], fn: hide }; function distanceAndSkiddingToXY(placement, rects, offset) { var basePlacement = getBasePlacement(placement); var invertDistance = [left, top].indexOf(basePlacement) >= 0 ? -1 : 1; var _ref = typeof offset === 'function' ? offset(Object.assign({}, rects, { placement: placement })) : offset, skidding = _ref[0], distance = _ref[1]; skidding = skidding || 0; distance = (distance || 0) * invertDistance; return [left, right].indexOf(basePlacement) >= 0 ? { x: distance, y: skidding } : { x: skidding, y: distance }; } function offset(_ref2) { var state = _ref2.state, options = _ref2.options, name = _ref2.name; var _options$offset = options.offset, offset = _options$offset === void 0 ? [0, 0] : _options$offset; var data = placements.reduce(function (acc, placement) { acc[placement] = distanceAndSkiddingToXY(placement, state.rects, offset); return acc; }, {}); var _data$state$placement = data[state.placement], x = _data$state$placement.x, y = _data$state$placement.y; if (state.modifiersData.popperOffsets != null) { state.modifiersData.popperOffsets.x += x; state.modifiersData.popperOffsets.y += y; } state.modifiersData[name] = data; } // eslint-disable-next-line import/no-unused-modules const offset$1 = { name: 'offset', enabled: true, phase: 'main', requires: ['popperOffsets'], fn: offset }; function popperOffsets(_ref) { var state = _ref.state, name = _ref.name; // Offsets are the actual position the popper needs to have to be // properly positioned near its reference element // This is the most basic placement, and will be adjusted by // the modifiers in the next step state.modifiersData[name] = computeOffsets({ reference: state.rects.reference, element: state.rects.popper, placement: state.placement }); } // eslint-disable-next-line import/no-unused-modules const popperOffsets$1 = { name: 'popperOffsets', enabled: true, phase: 'read', fn: popperOffsets, data: {} }; function getAltAxis(axis) { return axis === 'x' ? 'y' : 'x'; } function preventOverflow(_ref) { var state = _ref.state, options = _ref.options, name = _ref.name; var _options$mainAxis = options.mainAxis, checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, _options$altAxis = options.altAxis, checkAltAxis = _options$altAxis === void 0 ? false : _options$altAxis, boundary = options.boundary, rootBoundary = options.rootBoundary, altBoundary = options.altBoundary, padding = options.padding, _options$tether = options.tether, tether = _options$tether === void 0 ? true : _options$tether, _options$tetherOffset = options.tetherOffset, tetherOffset = _options$tetherOffset === void 0 ? 0 : _options$tetherOffset; var overflow = detectOverflow(state, { boundary: boundary, rootBoundary: rootBoundary, padding: padding, altBoundary: altBoundary }); var basePlacement = getBasePlacement(state.placement); var variation = getVariation(state.placement); var isBasePlacement = !variation; var mainAxis = getMainAxisFromPlacement(basePlacement); var altAxis = getAltAxis(mainAxis); var popperOffsets = state.modifiersData.popperOffsets; var referenceRect = state.rects.reference; var popperRect = state.rects.popper; var tetherOffsetValue = typeof tetherOffset === 'function' ? tetherOffset(Object.assign({}, state.rects, { placement: state.placement })) : tetherOffset; var normalizedTetherOffsetValue = typeof tetherOffsetValue === 'number' ? { mainAxis: tetherOffsetValue, altAxis: tetherOffsetValue } : Object.assign({ mainAxis: 0, altAxis: 0 }, tetherOffsetValue); var offsetModifierState = state.modifiersData.offset ? state.modifiersData.offset[state.placement] : null; var data = { x: 0, y: 0 }; if (!popperOffsets) { return; } if (checkMainAxis) { var _offsetModifierState$; var mainSide = mainAxis === 'y' ? top : left; var altSide = mainAxis === 'y' ? bottom : right; var len = mainAxis === 'y' ? 'height' : 'width'; var offset = popperOffsets[mainAxis]; var min$1 = offset + overflow[mainSide]; var max$1 = offset - overflow[altSide]; var additive = tether ? -popperRect[len] / 2 : 0; var minLen = variation === start ? referenceRect[len] : popperRect[len]; var maxLen = variation === start ? -popperRect[len] : -referenceRect[len]; // We need to include the arrow in the calculation so the arrow doesn't go // outside the reference bounds var arrowElement = state.elements.arrow; var arrowRect = tether && arrowElement ? getLayoutRect(arrowElement) : { width: 0, height: 0 }; var arrowPaddingObject = state.modifiersData['arrow#persistent'] ? state.modifiersData['arrow#persistent'].padding : getFreshSideObject(); var arrowPaddingMin = arrowPaddingObject[mainSide]; var arrowPaddingMax = arrowPaddingObject[altSide]; // If the reference length is smaller than the arrow length, we don't want // to include its full size in the calculation. If the reference is small // and near the edge of a boundary, the popper can overflow even if the // reference is not overflowing as well (e.g. virtual elements with no // width or height) var arrowLen = within(0, referenceRect[len], arrowRect[len]); var minOffset = isBasePlacement ? referenceRect[len] / 2 - additive - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis : minLen - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis; var maxOffset = isBasePlacement ? -referenceRect[len] / 2 + additive + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis : maxLen + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis; var arrowOffsetParent = state.elements.arrow && getOffsetParent(state.elements.arrow); var clientOffset = arrowOffsetParent ? mainAxis === 'y' ? arrowOffsetParent.clientTop || 0 : arrowOffsetParent.clientLeft || 0 : 0; var offsetModifierValue = (_offsetModifierState$ = offsetModifierState == null ? void 0 : offsetModifierState[mainAxis]) != null ? _offsetModifierState$ : 0; var tetherMin = offset + minOffset - offsetModifierValue - clientOffset; var tetherMax = offset + maxOffset - offsetModifierValue; var preventedOffset = within(tether ? min(min$1, tetherMin) : min$1, offset, tether ? max(max$1, tetherMax) : max$1); popperOffsets[mainAxis] = preventedOffset; data[mainAxis] = preventedOffset - offset; } if (checkAltAxis) { var _offsetModifierState$2; var _mainSide = mainAxis === 'x' ? top : left; var _altSide = mainAxis === 'x' ? bottom : right; var _offset = popperOffsets[altAxis]; var _len = altAxis === 'y' ? 'height' : 'width'; var _min = _offset + overflow[_mainSide]; var _max = _offset - overflow[_altSide]; var isOriginSide = [top, left].indexOf(basePlacement) !== -1; var _offsetModifierValue = (_offsetModifierState$2 = offsetModifierState == null ? void 0 : offsetModifierState[altAxis]) != null ? _offsetModifierState$2 : 0; var _tetherMin = isOriginSide ? _min : _offset - referenceRect[_len] - popperRect[_len] - _offsetModifierValue + normalizedTetherOffsetValue.altAxis; var _tetherMax = isOriginSide ? _offset + referenceRect[_len] + popperRect[_len] - _offsetModifierValue - normalizedTetherOffsetValue.altAxis : _max; var _preventedOffset = tether && isOriginSide ? withinMaxClamp(_tetherMin, _offset, _tetherMax) : within(tether ? _tetherMin : _min, _offset, tether ? _tetherMax : _max); popperOffsets[altAxis] = _preventedOffset; data[altAxis] = _preventedOffset - _offset; } state.modifiersData[name] = data; } // eslint-disable-next-line import/no-unused-modules const preventOverflow$1 = { name: 'preventOverflow', enabled: true, phase: 'main', fn: preventOverflow, requiresIfExists: ['offset'] }; function getHTMLElementScroll(element) { return { scrollLeft: element.scrollLeft, scrollTop: element.scrollTop }; } function getNodeScroll(node) { if (node === getWindow(node) || !isHTMLElement(node)) { return getWindowScroll(node); } else { return getHTMLElementScroll(node); } } function isElementScaled(element) { var rect = element.getBoundingClientRect(); var scaleX = round(rect.width) / element.offsetWidth || 1; var scaleY = round(rect.height) / element.offsetHeight || 1; return scaleX !== 1 || scaleY !== 1; } // Returns the composite rect of an element relative to its offsetParent. // Composite means it takes into account transforms as well as layout. function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) { if (isFixed === void 0) { isFixed = false; } var isOffsetParentAnElement = isHTMLElement(offsetParent); var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent); var documentElement = getDocumentElement(offsetParent); var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed); var scroll = { scrollLeft: 0, scrollTop: 0 }; var offsets = { x: 0, y: 0 }; if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078 isScrollParent(documentElement)) { scroll = getNodeScroll(offsetParent); } if (isHTMLElement(offsetParent)) { offsets = getBoundingClientRect(offsetParent, true); offsets.x += offsetParent.clientLeft; offsets.y += offsetParent.clientTop; } else if (documentElement) { offsets.x = getWindowScrollBarX(documentElement); } } return { x: rect.left + scroll.scrollLeft - offsets.x, y: rect.top + scroll.scrollTop - offsets.y, width: rect.width, height: rect.height }; } function order(modifiers) { var map = new Map(); var visited = new Set(); var result = []; modifiers.forEach(function (modifier) { map.set(modifier.name, modifier); }); // On visiting object, check for its dependencies and visit them recursively function sort(modifier) { visited.add(modifier.name); var requires = [].concat(modifier.requires || [], modifier.requiresIfExists || []); requires.forEach(function (dep) { if (!visited.has(dep)) { var depModifier = map.get(dep); if (depModifier) { sort(depModifier); } } }); result.push(modifier); } modifiers.forEach(function (modifier) { if (!visited.has(modifier.name)) { // check for visited object sort(modifier); } }); return result; } function orderModifiers(modifiers) { // order based on dependencies var orderedModifiers = order(modifiers); // order based on phase return modifierPhases.reduce(function (acc, phase) { return acc.concat(orderedModifiers.filter(function (modifier) { return modifier.phase === phase; })); }, []); } function debounce(fn) { var pending; return function () { if (!pending) { pending = new Promise(function (resolve) { Promise.resolve().then(function () { pending = undefined; resolve(fn()); }); }); } return pending; }; } function mergeByName(modifiers) { var merged = modifiers.reduce(function (merged, current) { var existing = merged[current.name]; merged[current.name] = existing ? Object.assign({}, existing, current, { options: Object.assign({}, existing.options, current.options), data: Object.assign({}, existing.data, current.data) }) : current; return merged; }, {}); // IE11 does not support Object.values return Object.keys(merged).map(function (key) { return merged[key]; }); } var DEFAULT_OPTIONS = { placement: 'bottom', modifiers: [], strategy: 'absolute' }; function areValidElements() { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } return !args.some(function (element) { return !(element && typeof element.getBoundingClientRect === 'function'); }); } function popperGenerator(generatorOptions) { if (generatorOptions === void 0) { generatorOptions = {}; } var _generatorOptions = generatorOptions, _generatorOptions$def = _generatorOptions.defaultModifiers, defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def, _generatorOptions$def2 = _generatorOptions.defaultOptions, defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2; return function createPopper(reference, popper, options) { if (options === void 0) { options = defaultOptions; } var state = { placement: 'bottom', orderedModifiers: [], options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions), modifiersData: {}, elements: { reference: reference, popper: popper }, attributes: {}, styles: {} }; var effectCleanupFns = []; var isDestroyed = false; var instance = { state: state, setOptions: function setOptions(setOptionsAction) { var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction; cleanupModifierEffects(); state.options = Object.assign({}, defaultOptions, state.options, options); state.scrollParents = { reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [], popper: listScrollParents(popper) }; // Orders the modifiers based on their dependencies and `phase` // properties var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers state.orderedModifiers = orderedModifiers.filter(function (m) { return m.enabled; }); runModifierEffects(); return instance.update(); }, // Sync update – it will always be executed, even if not necessary. This // is useful for low frequency updates where sync behavior simplifies the // logic. // For high frequency updates (e.g. `resize` and `scroll` events), always // prefer the async Popper#update method forceUpdate: function forceUpdate() { if (isDestroyed) { return; } var _state$elements = state.elements, reference = _state$elements.reference, popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements // anymore if (!areValidElements(reference, popper)) { return; } // Store the reference and popper rects to be read by modifiers state.rects = { reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'), popper: getLayoutRect(popper) }; // Modifiers have the ability to reset the current update cycle. The // most common use case for this is the `flip` modifier changing the // placement, which then needs to re-run all the modifiers, because the // logic was previously ran for the previous placement and is therefore // stale/incorrect state.reset = false; state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier // is filled with the initial data specified by the modifier. This means // it doesn't persist and is fresh on each update. // To ensure persistent data, use `${name}#persistent` state.orderedModifiers.forEach(function (modifier) { return state.modifiersData[modifier.name] = Object.assign({}, modifier.data); }); for (var index = 0; index < state.orderedModifiers.length; index++) { if (state.reset === true) { state.reset = false; index = -1; continue; } var _state$orderedModifie = state.orderedModifiers[index], fn = _state$orderedModifie.fn, _state$orderedModifie2 = _state$orderedModifie.options, _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2, name = _state$orderedModifie.name; if (typeof fn === 'function') { state = fn({ state: state, options: _options, name: name, instance: instance }) || state; } } }, // Async and optimistically optimized update – it will not be executed if // not necessary (debounced to run at most once-per-tick) update: debounce(function () { return new Promise(function (resolve) { instance.forceUpdate(); resolve(state); }); }), destroy: function destroy() { cleanupModifierEffects(); isDestroyed = true; } }; if (!areValidElements(reference, popper)) { return instance; } instance.setOptions(options).then(function (state) { if (!isDestroyed && options.onFirstUpdate) { options.onFirstUpdate(state); } }); // Modifiers have the ability to execute arbitrary code before the first // update cycle runs. They will be executed in the same order as the update // cycle. This is useful when a modifier adds some persistent data that // other modifiers need to use, but the modifier is run after the dependent // one. function runModifierEffects() { state.orderedModifiers.forEach(function (_ref) { var name = _ref.name, _ref$options = _ref.options, options = _ref$options === void 0 ? {} : _ref$options, effect = _ref.effect; if (typeof effect === 'function') { var cleanupFn = effect({ state: state, name: name, instance: instance, options: options }); var noopFn = function noopFn() {}; effectCleanupFns.push(cleanupFn || noopFn); } }); } function cleanupModifierEffects() { effectCleanupFns.forEach(function (fn) { return fn(); }); effectCleanupFns = []; } return instance; }; } var createPopper$2 = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules var defaultModifiers$1 = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1]; var createPopper$1 = /*#__PURE__*/popperGenerator({ defaultModifiers: defaultModifiers$1 }); // eslint-disable-next-line import/no-unused-modules var defaultModifiers = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1, offset$1, flip$1, preventOverflow$1, arrow$1, hide$1]; var createPopper = /*#__PURE__*/popperGenerator({ defaultModifiers: defaultModifiers }); // eslint-disable-next-line import/no-unused-modules const Popper = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({ __proto__: null, afterMain, afterRead, afterWrite, applyStyles: applyStyles$1, arrow: arrow$1, auto, basePlacements, beforeMain, beforeRead, beforeWrite, bottom, clippingParents, computeStyles: computeStyles$1, createPopper, createPopperBase: createPopper$2, createPopperLite: createPopper$1, detectOverflow, end, eventListeners, flip: flip$1, hide: hide$1, left, main, modifierPhases, offset: offset$1, placements, popper, popperGenerator, popperOffsets: popperOffsets$1, preventOverflow: preventOverflow$1, read, reference, right, start, top, variationPlacements, viewport, write }, Symbol.toStringTag, { value: 'Module' })); /** * -------------------------------------------------------------------------- * Bootstrap dropdown.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$a = 'dropdown'; const DATA_KEY$6 = 'bs.dropdown'; const EVENT_KEY$6 = `.${DATA_KEY$6}`; const DATA_API_KEY$3 = '.data-api'; const ESCAPE_KEY$2 = 'Escape'; const TAB_KEY$1 = 'Tab'; const ARROW_UP_KEY$1 = 'ArrowUp'; const ARROW_DOWN_KEY$1 = 'ArrowDown'; const RIGHT_MOUSE_BUTTON = 2; // MouseEvent.button value for the secondary button, usually the right button const EVENT_HIDE$5 = `hide${EVENT_KEY$6}`; const EVENT_HIDDEN$5 = `hidden${EVENT_KEY$6}`; const EVENT_SHOW$5 = `show${EVENT_KEY$6}`; const EVENT_SHOWN$5 = `shown${EVENT_KEY$6}`; const EVENT_CLICK_DATA_API$3 = `click${EVENT_KEY$6}${DATA_API_KEY$3}`; const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY$6}${DATA_API_KEY$3}`; const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY$6}${DATA_API_KEY$3}`; const CLASS_NAME_SHOW$6 = 'show'; const CLASS_NAME_DROPUP = 'dropup'; const CLASS_NAME_DROPEND = 'dropend'; const CLASS_NAME_DROPSTART = 'dropstart'; const CLASS_NAME_DROPUP_CENTER = 'dropup-center'; const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'; const SELECTOR_DATA_TOGGLE$3 = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)'; const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE$3}.${CLASS_NAME_SHOW$6}`; const SELECTOR_MENU = '.dropdown-menu'; const SELECTOR_NAVBAR = '.navbar'; const SELECTOR_NAVBAR_NAV = '.navbar-nav'; const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'; const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'; const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'; const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'; const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'; const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'; const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'; const PLACEMENT_TOPCENTER = 'top'; const PLACEMENT_BOTTOMCENTER = 'bottom'; const Default$9 = { autoClose: true, boundary: 'clippingParents', display: 'dynamic', offset: [0, 2], popperConfig: null, reference: 'toggle' }; const DefaultType$9 = { autoClose: '(boolean|string)', boundary: '(string|element)', display: 'string', offset: '(array|string|function)', popperConfig: '(null|object|function)', reference: '(string|element|object)' }; /** * Class definition */ class Dropdown extends BaseComponent { constructor(element, config) { super(element, config); this._popper = null; this._parent = this._element.parentNode; // dropdown wrapper // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] || SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || SelectorEngine.findOne(SELECTOR_MENU, this._parent); this._inNavbar = this._detectNavbar(); } // Getters static get Default() { return Default$9; } static get DefaultType() { return DefaultType$9; } static get NAME() { return NAME$a; } // Public toggle() { return this._isShown() ? this.hide() : this.show(); } show() { if (isDisabled(this._element) || this._isShown()) { return; } const relatedTarget = { relatedTarget: this._element }; const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$5, relatedTarget); if (showEvent.defaultPrevented) { return; } this._createPopper(); // If this is a touch-enabled device we add extra // empty mouseover listeners to the body's immediate children; // only needed because of broken event delegation on iOS // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) { for (const element of [].concat(...document.body.children)) { EventHandler.on(element, 'mouseover', noop); } } this._element.focus(); this._element.setAttribute('aria-expanded', true); this._menu.classList.add(CLASS_NAME_SHOW$6); this._element.classList.add(CLASS_NAME_SHOW$6); EventHandler.trigger(this._element, EVENT_SHOWN$5, relatedTarget); } hide() { if (isDisabled(this._element) || !this._isShown()) { return; } const relatedTarget = { relatedTarget: this._element }; this._completeHide(relatedTarget); } dispose() { if (this._popper) { this._popper.destroy(); } super.dispose(); } update() { this._inNavbar = this._detectNavbar(); if (this._popper) { this._popper.update(); } } // Private _completeHide(relatedTarget) { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$5, relatedTarget); if (hideEvent.defaultPrevented) { return; } // If this is a touch-enabled device we remove the extra // empty mouseover listeners we added for iOS support if ('ontouchstart' in document.documentElement) { for (const element of [].concat(...document.body.children)) { EventHandler.off(element, 'mouseover', noop); } } if (this._popper) { this._popper.destroy(); } this._menu.classList.remove(CLASS_NAME_SHOW$6); this._element.classList.remove(CLASS_NAME_SHOW$6); this._element.setAttribute('aria-expanded', 'false'); Manipulator.removeDataAttribute(this._menu, 'popper'); EventHandler.trigger(this._element, EVENT_HIDDEN$5, relatedTarget); } _getConfig(config) { config = super._getConfig(config); if (typeof config.reference === 'object' && !isElement$1(config.reference) && typeof config.reference.getBoundingClientRect !== 'function') { // Popper virtual elements require a getBoundingClientRect method throw new TypeError(`${NAME$a.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`); } return config; } _createPopper() { if (typeof Popper === 'undefined') { throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)'); } let referenceElement = this._element; if (this._config.reference === 'parent') { referenceElement = this._parent; } else if (isElement$1(this._config.reference)) { referenceElement = getElement(this._config.reference); } else if (typeof this._config.reference === 'object') { referenceElement = this._config.reference; } const popperConfig = this._getPopperConfig(); this._popper = createPopper(referenceElement, this._menu, popperConfig); } _isShown() { return this._menu.classList.contains(CLASS_NAME_SHOW$6); } _getPlacement() { const parentDropdown = this._parent; if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { return PLACEMENT_RIGHT; } if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { return PLACEMENT_LEFT; } if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) { return PLACEMENT_TOPCENTER; } if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) { return PLACEMENT_BOTTOMCENTER; } // We need to trim the value because custom properties can also include spaces const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'; if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP; } return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM; } _detectNavbar() { return this._element.closest(SELECTOR_NAVBAR) !== null; } _getOffset() { const { offset } = this._config; if (typeof offset === 'string') { return offset.split(',').map(value => Number.parseInt(value, 10)); } if (typeof offset === 'function') { return popperData => offset(popperData, this._element); } return offset; } _getPopperConfig() { const defaultBsPopperConfig = { placement: this._getPlacement(), modifiers: [{ name: 'preventOverflow', options: { boundary: this._config.boundary } }, { name: 'offset', options: { offset: this._getOffset() } }] }; // Disable Popper if we have a static display or Dropdown is in Navbar if (this._inNavbar || this._config.display === 'static') { Manipulator.setDataAttribute(this._menu, 'popper', 'static'); // TODO: v6 remove defaultBsPopperConfig.modifiers = [{ name: 'applyStyles', enabled: false }]; } return { ...defaultBsPopperConfig, ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) }; } _selectMenuItem({ key, target }) { const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element)); if (!items.length) { return; } // if target isn't included in items (e.g. when expanding the dropdown) // allow cycling to get the last item in case key equals ARROW_UP_KEY getNextActiveElement(items, target, key === ARROW_DOWN_KEY$1, !items.includes(target)).focus(); } // Static static jQueryInterface(config) { return this.each(function () { const data = Dropdown.getOrCreateInstance(this, config); if (typeof config !== 'string') { return; } if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`); } data[config](); }); } static clearMenus(event) { if (event.button === RIGHT_MOUSE_BUTTON || event.type === 'keyup' && event.key !== TAB_KEY$1) { return; } const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN); for (const toggle of openToggles) { const context = Dropdown.getInstance(toggle); if (!context || context._config.autoClose === false) { continue; } const composedPath = event.composedPath(); const isMenuTarget = composedPath.includes(context._menu); if (composedPath.includes(context._element) || context._config.autoClose === 'inside' && !isMenuTarget || context._config.autoClose === 'outside' && isMenuTarget) { continue; } // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu if (context._menu.contains(event.target) && (event.type === 'keyup' && event.key === TAB_KEY$1 || /input|select|option|textarea|form/i.test(event.target.tagName))) { continue; } const relatedTarget = { relatedTarget: context._element }; if (event.type === 'click') { relatedTarget.clickEvent = event; } context._completeHide(relatedTarget); } } static dataApiKeydownHandler(event) { // If not an UP | DOWN | ESCAPE key => not a dropdown command // If input/textarea && if key is other than ESCAPE => not a dropdown command const isInput = /input|textarea/i.test(event.target.tagName); const isEscapeEvent = event.key === ESCAPE_KEY$2; const isUpOrDownEvent = [ARROW_UP_KEY$1, ARROW_DOWN_KEY$1].includes(event.key); if (!isUpOrDownEvent && !isEscapeEvent) { return; } if (isInput && !isEscapeEvent) { return; } event.preventDefault(); // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE$3) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE$3)[0] || SelectorEngine.next(this, SELECTOR_DATA_TOGGLE$3)[0] || SelectorEngine.findOne(SELECTOR_DATA_TOGGLE$3, event.delegateTarget.parentNode); const instance = Dropdown.getOrCreateInstance(getToggleButton); if (isUpOrDownEvent) { event.stopPropagation(); instance.show(); instance._selectMenuItem(event); return; } if (instance._isShown()) { // else is escape and we check if it is shown event.stopPropagation(); instance.hide(); getToggleButton.focus(); } } } /** * Data API implementation */ EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE$3, Dropdown.dataApiKeydownHandler); EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler); EventHandler.on(document, EVENT_CLICK_DATA_API$3, Dropdown.clearMenus); EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus); EventHandler.on(document, EVENT_CLICK_DATA_API$3, SELECTOR_DATA_TOGGLE$3, function (event) { event.preventDefault(); Dropdown.getOrCreateInstance(this).toggle(); }); /** * jQuery */ defineJQueryPlugin(Dropdown); /** * -------------------------------------------------------------------------- * Bootstrap util/backdrop.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$9 = 'backdrop'; const CLASS_NAME_FADE$4 = 'fade'; const CLASS_NAME_SHOW$5 = 'show'; const EVENT_MOUSEDOWN = `mousedown.bs.${NAME$9}`; const Default$8 = { className: 'modal-backdrop', clickCallback: null, isAnimated: false, isVisible: true, // if false, we use the backdrop helper without adding any element to the dom rootElement: 'body' // give the choice to place backdrop under different elements }; const DefaultType$8 = { className: 'string', clickCallback: '(function|null)', isAnimated: 'boolean', isVisible: 'boolean', rootElement: '(element|string)' }; /** * Class definition */ class Backdrop extends Config { constructor(config) { super(); this._config = this._getConfig(config); this._isAppended = false; this._element = null; } // Getters static get Default() { return Default$8; } static get DefaultType() { return DefaultType$8; } static get NAME() { return NAME$9; } // Public show(callback) { if (!this._config.isVisible) { execute(callback); return; } this._append(); const element = this._getElement(); if (this._config.isAnimated) { reflow(element); } element.classList.add(CLASS_NAME_SHOW$5); this._emulateAnimation(() => { execute(callback); }); } hide(callback) { if (!this._config.isVisible) { execute(callback); return; } this._getElement().classList.remove(CLASS_NAME_SHOW$5); this._emulateAnimation(() => { this.dispose(); execute(callback); }); } dispose() { if (!this._isAppended) { return; } EventHandler.off(this._element, EVENT_MOUSEDOWN); this._element.remove(); this._isAppended = false; } // Private _getElement() { if (!this._element) { const backdrop = document.createElement('div'); backdrop.className = this._config.className; if (this._config.isAnimated) { backdrop.classList.add(CLASS_NAME_FADE$4); } this._element = backdrop; } return this._element; } _configAfterMerge(config) { // use getElement() with the default "body" to get a fresh Element on each instantiation config.rootElement = getElement(config.rootElement); return config; } _append() { if (this._isAppended) { return; } const element = this._getElement(); this._config.rootElement.append(element); EventHandler.on(element, EVENT_MOUSEDOWN, () => { execute(this._config.clickCallback); }); this._isAppended = true; } _emulateAnimation(callback) { executeAfterTransition(callback, this._getElement(), this._config.isAnimated); } } /** * -------------------------------------------------------------------------- * Bootstrap util/focustrap.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$8 = 'focustrap'; const DATA_KEY$5 = 'bs.focustrap'; const EVENT_KEY$5 = `.${DATA_KEY$5}`; const EVENT_FOCUSIN$2 = `focusin${EVENT_KEY$5}`; const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY$5}`; const TAB_KEY = 'Tab'; const TAB_NAV_FORWARD = 'forward'; const TAB_NAV_BACKWARD = 'backward'; const Default$7 = { autofocus: true, trapElement: null // The element to trap focus inside of }; const DefaultType$7 = { autofocus: 'boolean', trapElement: 'element' }; /** * Class definition */ class FocusTrap extends Config { constructor(config) { super(); this._config = this._getConfig(config); this._isActive = false; this._lastTabNavDirection = null; } // Getters static get Default() { return Default$7; } static get DefaultType() { return DefaultType$7; } static get NAME() { return NAME$8; } // Public activate() { if (this._isActive) { return; } if (this._config.autofocus) { this._config.trapElement.focus(); } EventHandler.off(document, EVENT_KEY$5); // guard against infinite focus loop EventHandler.on(document, EVENT_FOCUSIN$2, event => this._handleFocusin(event)); EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event)); this._isActive = true; } deactivate() { if (!this._isActive) { return; } this._isActive = false; EventHandler.off(document, EVENT_KEY$5); } // Private _handleFocusin(event) { const { trapElement } = this._config; if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) { return; } const elements = SelectorEngine.focusableChildren(trapElement); if (elements.length === 0) { trapElement.focus(); } else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) { elements[elements.length - 1].focus(); } else { elements[0].focus(); } } _handleKeydown(event) { if (event.key !== TAB_KEY) { return; } this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD; } } /** * -------------------------------------------------------------------------- * Bootstrap util/scrollBar.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'; const SELECTOR_STICKY_CONTENT = '.sticky-top'; const PROPERTY_PADDING = 'padding-right'; const PROPERTY_MARGIN = 'margin-right'; /** * Class definition */ class ScrollBarHelper { constructor() { this._element = document.body; } // Public getWidth() { // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes const documentWidth = document.documentElement.clientWidth; return Math.abs(window.innerWidth - documentWidth); } hide() { const width = this.getWidth(); this._disableOverFlow(); // give padding to element to balance the hidden scrollbar width this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width); // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width); this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width); } reset() { this._resetElementAttributes(this._element, 'overflow'); this._resetElementAttributes(this._element, PROPERTY_PADDING); this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING); this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN); } isOverflowing() { return this.getWidth() > 0; } // Private _disableOverFlow() { this._saveInitialAttribute(this._element, 'overflow'); this._element.style.overflow = 'hidden'; } _setElementAttributes(selector, styleProperty, callback) { const scrollbarWidth = this.getWidth(); const manipulationCallBack = element => { if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) { return; } this._saveInitialAttribute(element, styleProperty); const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty); element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`); }; this._applyManipulationCallback(selector, manipulationCallBack); } _saveInitialAttribute(element, styleProperty) { const actualValue = element.style.getPropertyValue(styleProperty); if (actualValue) { Manipulator.setDataAttribute(element, styleProperty, actualValue); } } _resetElementAttributes(selector, styleProperty) { const manipulationCallBack = element => { const value = Manipulator.getDataAttribute(element, styleProperty); // We only want to remove the property if the value is `null`; the value can also be zero if (value === null) { element.style.removeProperty(styleProperty); return; } Manipulator.removeDataAttribute(element, styleProperty); element.style.setProperty(styleProperty, value); }; this._applyManipulationCallback(selector, manipulationCallBack); } _applyManipulationCallback(selector, callBack) { if (isElement$1(selector)) { callBack(selector); return; } for (const sel of SelectorEngine.find(selector, this._element)) { callBack(sel); } } } /** * -------------------------------------------------------------------------- * Bootstrap modal.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$7 = 'modal'; const DATA_KEY$4 = 'bs.modal'; const EVENT_KEY$4 = `.${DATA_KEY$4}`; const DATA_API_KEY$2 = '.data-api'; const ESCAPE_KEY$1 = 'Escape'; const EVENT_HIDE$4 = `hide${EVENT_KEY$4}`; const EVENT_HIDE_PREVENTED$1 = `hidePrevented${EVENT_KEY$4}`; const EVENT_HIDDEN$4 = `hidden${EVENT_KEY$4}`; const EVENT_SHOW$4 = `show${EVENT_KEY$4}`; const EVENT_SHOWN$4 = `shown${EVENT_KEY$4}`; const EVENT_RESIZE$1 = `resize${EVENT_KEY$4}`; const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY$4}`; const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY$4}`; const EVENT_KEYDOWN_DISMISS$1 = `keydown.dismiss${EVENT_KEY$4}`; const EVENT_CLICK_DATA_API$2 = `click${EVENT_KEY$4}${DATA_API_KEY$2}`; const CLASS_NAME_OPEN = 'modal-open'; const CLASS_NAME_FADE$3 = 'fade'; const CLASS_NAME_SHOW$4 = 'show'; const CLASS_NAME_STATIC = 'modal-static'; const OPEN_SELECTOR$1 = '.modal.show'; const SELECTOR_DIALOG = '.modal-dialog'; const SELECTOR_MODAL_BODY = '.modal-body'; const SELECTOR_DATA_TOGGLE$2 = '[data-bs-toggle="modal"]'; const Default$6 = { backdrop: true, focus: true, keyboard: true }; const DefaultType$6 = { backdrop: '(boolean|string)', focus: 'boolean', keyboard: 'boolean' }; /** * Class definition */ class Modal extends BaseComponent { constructor(element, config) { super(element, config); this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element); this._backdrop = this._initializeBackDrop(); this._focustrap = this._initializeFocusTrap(); this._isShown = false; this._isTransitioning = false; this._scrollBar = new ScrollBarHelper(); this._addEventListeners(); } // Getters static get Default() { return Default$6; } static get DefaultType() { return DefaultType$6; } static get NAME() { return NAME$7; } // Public toggle(relatedTarget) { return this._isShown ? this.hide() : this.show(relatedTarget); } show(relatedTarget) { if (this._isShown || this._isTransitioning) { return; } const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$4, { relatedTarget }); if (showEvent.defaultPrevented) { return; } this._isShown = true; this._isTransitioning = true; this._scrollBar.hide(); document.body.classList.add(CLASS_NAME_OPEN); this._adjustDialog(); this._backdrop.show(() => this._showElement(relatedTarget)); } hide() { if (!this._isShown || this._isTransitioning) { return; } const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$4); if (hideEvent.defaultPrevented) { return; } this._isShown = false; this._isTransitioning = true; this._focustrap.deactivate(); this._element.classList.remove(CLASS_NAME_SHOW$4); this._queueCallback(() => this._hideModal(), this._element, this._isAnimated()); } dispose() { EventHandler.off(window, EVENT_KEY$4); EventHandler.off(this._dialog, EVENT_KEY$4); this._backdrop.dispose(); this._focustrap.deactivate(); super.dispose(); } handleUpdate() { this._adjustDialog(); } // Private _initializeBackDrop() { return new Backdrop({ isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value, isAnimated: this._isAnimated() }); } _initializeFocusTrap() { return new FocusTrap({ trapElement: this._element }); } _showElement(relatedTarget) { // try to append dynamic modal if (!document.body.contains(this._element)) { document.body.append(this._element); } this._element.style.display = 'block'; this._element.removeAttribute('aria-hidden'); this._element.setAttribute('aria-modal', true); this._element.setAttribute('role', 'dialog'); this._element.scrollTop = 0; const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog); if (modalBody) { modalBody.scrollTop = 0; } reflow(this._element); this._element.classList.add(CLASS_NAME_SHOW$4); const transitionComplete = () => { if (this._config.focus) { this._focustrap.activate(); } this._isTransitioning = false; EventHandler.trigger(this._element, EVENT_SHOWN$4, { relatedTarget }); }; this._queueCallback(transitionComplete, this._dialog, this._isAnimated()); } _addEventListeners() { EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS$1, event => { if (event.key !== ESCAPE_KEY$1) { return; } if (this._config.keyboard) { this.hide(); return; } this._triggerBackdropTransition(); }); EventHandler.on(window, EVENT_RESIZE$1, () => { if (this._isShown && !this._isTransitioning) { this._adjustDialog(); } }); EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => { // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => { if (this._element !== event.target || this._element !== event2.target) { return; } if (this._config.backdrop === 'static') { this._triggerBackdropTransition(); return; } if (this._config.backdrop) { this.hide(); } }); }); } _hideModal() { this._element.style.display = 'none'; this._element.setAttribute('aria-hidden', true); this._element.removeAttribute('aria-modal'); this._element.removeAttribute('role'); this._isTransitioning = false; this._backdrop.hide(() => { document.body.classList.remove(CLASS_NAME_OPEN); this._resetAdjustments(); this._scrollBar.reset(); EventHandler.trigger(this._element, EVENT_HIDDEN$4); }); } _isAnimated() { return this._element.classList.contains(CLASS_NAME_FADE$3); } _triggerBackdropTransition() { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED$1); if (hideEvent.defaultPrevented) { return; } const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; const initialOverflowY = this._element.style.overflowY; // return if the following background transition hasn't yet completed if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) { return; } if (!isModalOverflowing) { this._element.style.overflowY = 'hidden'; } this._element.classList.add(CLASS_NAME_STATIC); this._queueCallback(() => { this._element.classList.remove(CLASS_NAME_STATIC); this._queueCallback(() => { this._element.style.overflowY = initialOverflowY; }, this._dialog); }, this._dialog); this._element.focus(); } /** * The following methods are used to handle overflowing modals */ _adjustDialog() { const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; const scrollbarWidth = this._scrollBar.getWidth(); const isBodyOverflowing = scrollbarWidth > 0; if (isBodyOverflowing && !isModalOverflowing) { const property = isRTL() ? 'paddingLeft' : 'paddingRight'; this._element.style[property] = `${scrollbarWidth}px`; } if (!isBodyOverflowing && isModalOverflowing) { const property = isRTL() ? 'paddingRight' : 'paddingLeft'; this._element.style[property] = `${scrollbarWidth}px`; } } _resetAdjustments() { this._element.style.paddingLeft = ''; this._element.style.paddingRight = ''; } // Static static jQueryInterface(config, relatedTarget) { return this.each(function () { const data = Modal.getOrCreateInstance(this, config); if (typeof config !== 'string') { return; } if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`); } data[config](relatedTarget); }); } } /** * Data API implementation */ EventHandler.on(document, EVENT_CLICK_DATA_API$2, SELECTOR_DATA_TOGGLE$2, function (event) { const target = SelectorEngine.getElementFromSelector(this); if (['A', 'AREA'].includes(this.tagName)) { event.preventDefault(); } EventHandler.one(target, EVENT_SHOW$4, showEvent => { if (showEvent.defaultPrevented) { // only register focus restorer if modal will actually get shown return; } EventHandler.one(target, EVENT_HIDDEN$4, () => { if (isVisible(this)) { this.focus(); } }); }); // avoid conflict when clicking modal toggler while another one is open const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR$1); if (alreadyOpen) { Modal.getInstance(alreadyOpen).hide(); } const data = Modal.getOrCreateInstance(target); data.toggle(this); }); enableDismissTrigger(Modal); /** * jQuery */ defineJQueryPlugin(Modal); /** * -------------------------------------------------------------------------- * Bootstrap offcanvas.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$6 = 'offcanvas'; const DATA_KEY$3 = 'bs.offcanvas'; const EVENT_KEY$3 = `.${DATA_KEY$3}`; const DATA_API_KEY$1 = '.data-api'; const EVENT_LOAD_DATA_API$2 = `load${EVENT_KEY$3}${DATA_API_KEY$1}`; const ESCAPE_KEY = 'Escape'; const CLASS_NAME_SHOW$3 = 'show'; const CLASS_NAME_SHOWING$1 = 'showing'; const CLASS_NAME_HIDING = 'hiding'; const CLASS_NAME_BACKDROP = 'offcanvas-backdrop'; const OPEN_SELECTOR = '.offcanvas.show'; const EVENT_SHOW$3 = `show${EVENT_KEY$3}`; const EVENT_SHOWN$3 = `shown${EVENT_KEY$3}`; const EVENT_HIDE$3 = `hide${EVENT_KEY$3}`; const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY$3}`; const EVENT_HIDDEN$3 = `hidden${EVENT_KEY$3}`; const EVENT_RESIZE = `resize${EVENT_KEY$3}`; const EVENT_CLICK_DATA_API$1 = `click${EVENT_KEY$3}${DATA_API_KEY$1}`; const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY$3}`; const SELECTOR_DATA_TOGGLE$1 = '[data-bs-toggle="offcanvas"]'; const Default$5 = { backdrop: true, keyboard: true, scroll: false }; const DefaultType$5 = { backdrop: '(boolean|string)', keyboard: 'boolean', scroll: 'boolean' }; /** * Class definition */ class Offcanvas extends BaseComponent { constructor(element, config) { super(element, config); this._isShown = false; this._backdrop = this._initializeBackDrop(); this._focustrap = this._initializeFocusTrap(); this._addEventListeners(); } // Getters static get Default() { return Default$5; } static get DefaultType() { return DefaultType$5; } static get NAME() { return NAME$6; } // Public toggle(relatedTarget) { return this._isShown ? this.hide() : this.show(relatedTarget); } show(relatedTarget) { if (this._isShown) { return; } const showEvent = EventHandler.trigger(this._element, EVENT_SHOW$3, { relatedTarget }); if (showEvent.defaultPrevented) { return; } this._isShown = true; this._backdrop.show(); if (!this._config.scroll) { new ScrollBarHelper().hide(); } this._element.setAttribute('aria-modal', true); this._element.setAttribute('role', 'dialog'); this._element.classList.add(CLASS_NAME_SHOWING$1); const completeCallBack = () => { if (!this._config.scroll || this._config.backdrop) { this._focustrap.activate(); } this._element.classList.add(CLASS_NAME_SHOW$3); this._element.classList.remove(CLASS_NAME_SHOWING$1); EventHandler.trigger(this._element, EVENT_SHOWN$3, { relatedTarget }); }; this._queueCallback(completeCallBack, this._element, true); } hide() { if (!this._isShown) { return; } const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE$3); if (hideEvent.defaultPrevented) { return; } this._focustrap.deactivate(); this._element.blur(); this._isShown = false; this._element.classList.add(CLASS_NAME_HIDING); this._backdrop.hide(); const completeCallback = () => { this._element.classList.remove(CLASS_NAME_SHOW$3, CLASS_NAME_HIDING); this._element.removeAttribute('aria-modal'); this._element.removeAttribute('role'); if (!this._config.scroll) { new ScrollBarHelper().reset(); } EventHandler.trigger(this._element, EVENT_HIDDEN$3); }; this._queueCallback(completeCallback, this._element, true); } dispose() { this._backdrop.dispose(); this._focustrap.deactivate(); super.dispose(); } // Private _initializeBackDrop() { const clickCallback = () => { if (this._config.backdrop === 'static') { EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED); return; } this.hide(); }; // 'static' option will be translated to true, and booleans will keep their value const isVisible = Boolean(this._config.backdrop); return new Backdrop({ className: CLASS_NAME_BACKDROP, isVisible, isAnimated: true, rootElement: this._element.parentNode, clickCallback: isVisible ? clickCallback : null }); } _initializeFocusTrap() { return new FocusTrap({ trapElement: this._element }); } _addEventListeners() { EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { if (event.key !== ESCAPE_KEY) { return; } if (this._config.keyboard) { this.hide(); return; } EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED); }); } // Static static jQueryInterface(config) { return this.each(function () { const data = Offcanvas.getOrCreateInstance(this, config); if (typeof config !== 'string') { return; } if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { throw new TypeError(`No method named "${config}"`); } data[config](this); }); } } /** * Data API implementation */ EventHandler.on(document, EVENT_CLICK_DATA_API$1, SELECTOR_DATA_TOGGLE$1, function (event) { const target = SelectorEngine.getElementFromSelector(this); if (['A', 'AREA'].includes(this.tagName)) { event.preventDefault(); } if (isDisabled(this)) { return; } EventHandler.one(target, EVENT_HIDDEN$3, () => { // focus on trigger when it is closed if (isVisible(this)) { this.focus(); } }); // avoid conflict when clicking a toggler of an offcanvas, while another is open const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR); if (alreadyOpen && alreadyOpen !== target) { Offcanvas.getInstance(alreadyOpen).hide(); } const data = Offcanvas.getOrCreateInstance(target); data.toggle(this); }); EventHandler.on(window, EVENT_LOAD_DATA_API$2, () => { for (const selector of SelectorEngine.find(OPEN_SELECTOR)) { Offcanvas.getOrCreateInstance(selector).show(); } }); EventHandler.on(window, EVENT_RESIZE, () => { for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) { if (getComputedStyle(element).position !== 'fixed') { Offcanvas.getOrCreateInstance(element).hide(); } } }); enableDismissTrigger(Offcanvas); /** * jQuery */ defineJQueryPlugin(Offcanvas); /** * -------------------------------------------------------------------------- * Bootstrap util/sanitizer.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ // js-docs-start allow-list const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i; const DefaultAllowlist = { // Global attributes allowed on any supplied element below. '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN], a: ['target', 'href', 'title', 'rel'], area: [], b: [], br: [], col: [], code: [], dd: [], div: [], dl: [], dt: [], em: [], hr: [], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [], i: [], img: ['src', 'srcset', 'alt', 'title', 'width', 'height'], li: [], ol: [], p: [], pre: [], s: [], small: [], span: [], sub: [], sup: [], strong: [], u: [], ul: [] }; // js-docs-end allow-list const uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longdesc', 'poster', 'src', 'xlink:href']); /** * A pattern that recognizes URLs that are safe wrt. XSS in URL navigation * contexts. * * Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38 */ const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i; const allowedAttribute = (attribute, allowedAttributeList) => { const attributeName = attribute.nodeName.toLowerCase(); if (allowedAttributeList.includes(attributeName)) { if (uriAttributes.has(attributeName)) { return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue)); } return true; } // Check if a regular expression validates the attribute. return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp).some(regex => regex.test(attributeName)); }; function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) { if (!unsafeHtml.length) { return unsafeHtml; } if (sanitizeFunction && typeof sanitizeFunction === 'function') { return sanitizeFunction(unsafeHtml); } const domParser = new window.DOMParser(); const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html'); const elements = [].concat(...createdDocument.body.querySelectorAll('*')); for (const element of elements) { const elementName = element.nodeName.toLowerCase(); if (!Object.keys(allowList).includes(elementName)) { element.remove(); continue; } const attributeList = [].concat(...element.attributes); const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || []); for (const attribute of attributeList) { if (!allowedAttribute(attribute, allowedAttributes)) { element.removeAttribute(attribute.nodeName); } } } return createdDocument.body.innerHTML; } /** * -------------------------------------------------------------------------- * Bootstrap util/template-factory.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$5 = 'TemplateFactory'; const Default$4 = { allowList: DefaultAllowlist, content: {}, // { selector : text , selector2 : text2 , } extraClass: '', html: false, sanitize: true, sanitizeFn: null, template: '
' }; const DefaultType$4 = { allowList: 'object', content: 'object', extraClass: '(string|function)', html: 'boolean', sanitize: 'boolean', sanitizeFn: '(null|function)', template: 'string' }; const DefaultContentType = { entry: '(string|element|function|null)', selector: '(string|element)' }; /** * Class definition */ class TemplateFactory extends Config { constructor(config) { super(); this._config = this._getConfig(config); } // Getters static get Default() { return Default$4; } static get DefaultType() { return DefaultType$4; } static get NAME() { return NAME$5; } // Public getContent() { return Object.values(this._config.content).map(config => this._resolvePossibleFunction(config)).filter(Boolean); } hasContent() { return this.getContent().length > 0; } changeContent(content) { this._checkContent(content); this._config.content = { ...this._config.content, ...content }; return this; } toHtml() { const templateWrapper = document.createElement('div'); templateWrapper.innerHTML = this._maybeSanitize(this._config.template); for (const [selector, text] of Object.entries(this._config.content)) { this._setContent(templateWrapper, text, selector); } const template = templateWrapper.children[0]; const extraClass = this._resolvePossibleFunction(this._config.extraClass); if (extraClass) { template.classList.add(...extraClass.split(' ')); } return template; } // Private _typeCheckConfig(config) { super._typeCheckConfig(config); this._checkContent(config.content); } _checkContent(arg) { for (const [selector, content] of Object.entries(arg)) { super._typeCheckConfig({ selector, entry: content }, DefaultContentType); } } _setContent(template, content, selector) { const templateElement = SelectorEngine.findOne(selector, template); if (!templateElement) { return; } content = this._resolvePossibleFunction(content); if (!content) { templateElement.remove(); return; } if (isElement$1(content)) { this._putElementInTemplate(getElement(content), templateElement); return; } if (this._config.html) { templateElement.innerHTML = this._maybeSanitize(content); return; } templateElement.textContent = content; } _maybeSanitize(arg) { return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg; } _resolvePossibleFunction(arg) { return execute(arg, [undefined, this]); } _putElementInTemplate(element, templateElement) { if (this._config.html) { templateElement.innerHTML = ''; templateElement.append(element); return; } templateElement.textContent = element.textContent; } } /** * -------------------------------------------------------------------------- * Bootstrap tooltip.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$4 = 'tooltip'; const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']); const CLASS_NAME_FADE$2 = 'fade'; const CLASS_NAME_MODAL = 'modal'; const CLASS_NAME_SHOW$2 = 'show'; const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'; const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`; const EVENT_MODAL_HIDE = 'hide.bs.modal'; const TRIGGER_HOVER = 'hover'; const TRIGGER_FOCUS = 'focus'; const TRIGGER_CLICK = 'click'; const TRIGGER_MANUAL = 'manual'; const EVENT_HIDE$2 = 'hide'; const EVENT_HIDDEN$2 = 'hidden'; const EVENT_SHOW$2 = 'show'; const EVENT_SHOWN$2 = 'shown'; const EVENT_INSERTED = 'inserted'; const EVENT_CLICK$1 = 'click'; const EVENT_FOCUSIN$1 = 'focusin'; const EVENT_FOCUSOUT$1 = 'focusout'; const EVENT_MOUSEENTER = 'mouseenter'; const EVENT_MOUSELEAVE = 'mouseleave'; const AttachmentMap = { AUTO: 'auto', TOP: 'top', RIGHT: isRTL() ? 'left' : 'right', BOTTOM: 'bottom', LEFT: isRTL() ? 'right' : 'left' }; const Default$3 = { allowList: DefaultAllowlist, animation: true, boundary: 'clippingParents', container: false, customClass: '', delay: 0, fallbackPlacements: ['top', 'right', 'bottom', 'left'], html: false, offset: [0, 6], placement: 'top', popperConfig: null, sanitize: true, sanitizeFn: null, selector: false, template: '', title: '', trigger: 'hover focus' }; const DefaultType$3 = { allowList: 'object', animation: 'boolean', boundary: '(string|element)', container: '(string|element|boolean)', customClass: '(string|function)', delay: '(number|object)', fallbackPlacements: 'array', html: 'boolean', offset: '(array|string|function)', placement: '(string|function)', popperConfig: '(null|object|function)', sanitize: 'boolean', sanitizeFn: '(null|function)', selector: '(string|boolean)', template: 'string', title: '(string|element|function)', trigger: 'string' }; /** * Class definition */ class Tooltip extends BaseComponent { constructor(element, config) { if (typeof Popper === 'undefined') { throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)'); } super(element, config); // Private this._isEnabled = true; this._timeout = 0; this._isHovered = null; this._activeTrigger = {}; this._popper = null; this._templateFactory = null; this._newContent = null; // Protected this.tip = null; this._setListeners(); if (!this._config.selector) { this._fixTitle(); } } // Getters static get Default() { return Default$3; } static get DefaultType() { return DefaultType$3; } static get NAME() { return NAME$4; } // Public enable() { this._isEnabled = true; } disable() { this._isEnabled = false; } toggleEnabled() { this._isEnabled = !this._isEnabled; } toggle() { if (!this._isEnabled) { return; } if (this._isShown()) { this._leave(); return; } this._enter(); } dispose() { clearTimeout(this._timeout); EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler); if (this._element.getAttribute('data-bs-original-title')) { this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title')); } this._disposePopper(); super.dispose(); } show() { if (this._element.style.display === 'none') { throw new Error('Please use show on visible elements'); } if (!(this._isWithContent() && this._isEnabled)) { return; } const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW$2)); const shadowRoot = findShadowRoot(this._element); const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element); if (showEvent.defaultPrevented || !isInTheDom) { return; } // TODO: v6 remove this or make it optional this._disposePopper(); const tip = this._getTipElement(); this._element.setAttribute('aria-describedby', tip.getAttribute('id')); const { container } = this._config; if (!this._element.ownerDocument.documentElement.contains(this.tip)) { container.append(tip); EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED)); } this._popper = this._createPopper(tip); tip.classList.add(CLASS_NAME_SHOW$2); // If this is a touch-enabled device we add extra // empty mouseover listeners to the body's immediate children; // only needed because of broken event delegation on iOS // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html if ('ontouchstart' in document.documentElement) { for (const element of [].concat(...document.body.children)) { EventHandler.on(element, 'mouseover', noop); } } const complete = () => { EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN$2)); if (this._isHovered === false) { this._leave(); } this._isHovered = false; }; this._queueCallback(complete, this.tip, this._isAnimated()); } hide() { if (!this._isShown()) { return; } const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE$2)); if (hideEvent.defaultPrevented) { return; } const tip = this._getTipElement(); tip.classList.remove(CLASS_NAME_SHOW$2); // If this is a touch-enabled device we remove the extra // empty mouseover listeners we added for iOS support if ('ontouchstart' in document.documentElement) { for (const element of [].concat(...document.body.children)) { EventHandler.off(element, 'mouseover', noop); } } this._activeTrigger[TRIGGER_CLICK] = false; this._activeTrigger[TRIGGER_FOCUS] = false; this._activeTrigger[TRIGGER_HOVER] = false; this._isHovered = null; // it is a trick to support manual triggering const complete = () => { if (this._isWithActiveTrigger()) { return; } if (!this._isHovered) { this._disposePopper(); } this._element.removeAttribute('aria-describedby'); EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN$2)); }; this._queueCallback(complete, this.tip, this._isAnimated()); } update() { if (this._popper) { this._popper.update(); } } // Protected _isWithContent() { return Boolean(this._getTitle()); } _getTipElement() { if (!this.tip) { this.tip = this._createTipElement(this._newContent || this._getContentForTemplate()); } return this.tip; } _createTipElement(content) { const tip = this._getTemplateFactory(content).toHtml(); // TODO: remove this check in v6 if (!tip) { return null; } tip.classList.remove(CLASS_NAME_FADE$2, CLASS_NAME_SHOW$2); // TODO: v6 the following can be achieved with CSS only tip.classList.add(`bs-${this.constructor.NAME}-auto`); const tipId = getUID(this.constructor.NAME).toString(); tip.setAttribute('id', tipId); if (this._isAnimated()) { tip.classList.add(CLASS_NAME_FADE$2); } return tip; } setContent(content) { this._newContent = content; if (this._isShown()) { this._disposePopper(); this.show(); } } _getTemplateFactory(content) { if (this._templateFactory) { this._templateFactory.changeContent(content); } else { this._templateFactory = new TemplateFactory({ ...this._config, // the `content` var has to be after `this._config` // to override config.content in case of popover content, extraClass: this._resolvePossibleFunction(this._config.customClass) }); } return this._templateFactory; } _getContentForTemplate() { return { [SELECTOR_TOOLTIP_INNER]: this._getTitle() }; } _getTitle() { return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title'); } // Private _initializeOnDelegatedTarget(event) { return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig()); } _isAnimated() { return this._config.animation || this.tip && this.tip.classList.contains(CLASS_NAME_FADE$2); } _isShown() { return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW$2); } _createPopper(tip) { const placement = execute(this._config.placement, [this, tip, this._element]); const attachment = AttachmentMap[placement.toUpperCase()]; return createPopper(this._element, tip, this._getPopperConfig(attachment)); } _getOffset() { const { offset } = this._config; if (typeof offset === 'string') { return offset.split(',').map(value => Number.parseInt(value, 10)); } if (typeof offset === 'function') { return popperData => offset(popperData, this._element); } return offset; } _resolvePossibleFunction(arg) { return execute(arg, [this._element, this._element]); } _getPopperConfig(attachment) { const defaultBsPopperConfig = { placement: attachment, modifiers: [{ name: 'flip', options: { fallbackPlacements: this._config.fallbackPlacements } }, { name: 'offset', options: { offset: this._getOffset() } }, { name: 'preventOverflow', options: { boundary: this._config.boundary } }, { name: 'arrow', options: { element: `.${this.constructor.NAME}-arrow` } }, { name: 'preSetPlacement', enabled: true, phase: 'beforeMain', fn: data => { // Pre-set Popper's placement attribute in order to read the arrow sizes properly. // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement this._getTipElement().setAttribute('data-popper-placement', data.state.placement); } }] }; return { ...defaultBsPopperConfig, ...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig]) }; } _setListeners() { const triggers = this._config.trigger.split(' '); for (const trigger of triggers) { if (trigger === 'click') { EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event); context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]); context.toggle(); }); } else if (trigger !== TRIGGER_MANUAL) { const eventIn = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSEENTER) : this.constructor.eventName(EVENT_FOCUSIN$1); const eventOut = trigger === TRIGGER_HOVER ? this.constructor.eventName(EVENT_MOUSELEAVE) : this.constructor.eventName(EVENT_FOCUSOUT$1); EventHandler.on(this._element, eventIn, this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event); context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true; context._enter(); }); EventHandler.on(this._element, eventOut, this._config.selector, event => { const context = this._initializeOnDelegatedTarget(event); context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] = context._element.contains(event.relatedTarget); context._leave(); }); } } this._hideModalHandler = () => { if (this._element) { this.hide(); } }; EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler); } _fixTitle() { const title = this._element.getAttribute('title'); if (!title) { return; } if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) { this._element.setAttribute('aria-label', title); } this._element.setAttribute('data-bs-original-title', title); // DO NOT USE IT. Is only for backwards compatibility this._element.removeAttribute('title'); } _enter() { if (this._isShown() || this._isHovered) { this._isHovered = true; return; } this._isHovered = true; this._setTimeout(() => { if (this._isHovered) { this.show(); } }, this._config.delay.show); } _leave() { if (this._isWithActiveTrigger()) { return; } this._isHovered = false; this._setTimeout(() => { if (!this._isHovered) { this.hide(); } }, this._config.delay.hide); } _setTimeout(handler, timeout) { clearTimeout(this._timeout); this._timeout = setTimeout(handler, timeout); } _isWithActiveTrigger() { return Object.values(this._activeTrigger).includes(true); } _getConfig(config) { const dataAttributes = Manipulator.getDataAttributes(this._element); for (const dataAttribute of Object.keys(dataAttributes)) { if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { delete dataAttributes[dataAttribute]; } } config = { ...dataAttributes, ...(typeof config === 'object' && config ? config : {}) }; config = this._mergeConfigObj(config); config = this._configAfterMerge(config); this._typeCheckConfig(config); return config; } _configAfterMerge(config) { config.container = config.container === false ? document.body : getElement(config.container); if (typeof config.delay === 'number') { config.delay = { show: config.delay, hide: config.delay }; } if (typeof config.title === 'number') { config.title = config.title.toString(); } if (typeof config.content === 'number') { config.content = config.content.toString(); } return config; } _getDelegateConfig() { const config = {}; for (const [key, value] of Object.entries(this._config)) { if (this.constructor.Default[key] !== value) { config[key] = value; } } config.selector = false; config.trigger = 'manual'; // In the future can be replaced with: // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]]) // `Object.fromEntries(keysWithDifferentValues)` return config; } _disposePopper() { if (this._popper) { this._popper.destroy(); this._popper = null; } if (this.tip) { this.tip.remove(); this.tip = null; } } // Static static jQueryInterface(config) { return this.each(function () { const data = Tooltip.getOrCreateInstance(this, config); if (typeof config !== 'string') { return; } if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`); } data[config](); }); } } /** * jQuery */ defineJQueryPlugin(Tooltip); /** * -------------------------------------------------------------------------- * Bootstrap popover.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$3 = 'popover'; const SELECTOR_TITLE = '.popover-header'; const SELECTOR_CONTENT = '.popover-body'; const Default$2 = { ...Tooltip.Default, content: '', offset: [0, 8], placement: 'right', template: '', trigger: 'click' }; const DefaultType$2 = { ...Tooltip.DefaultType, content: '(null|string|element|function)' }; /** * Class definition */ class Popover extends Tooltip { // Getters static get Default() { return Default$2; } static get DefaultType() { return DefaultType$2; } static get NAME() { return NAME$3; } // Overrides _isWithContent() { return this._getTitle() || this._getContent(); } // Private _getContentForTemplate() { return { [SELECTOR_TITLE]: this._getTitle(), [SELECTOR_CONTENT]: this._getContent() }; } _getContent() { return this._resolvePossibleFunction(this._config.content); } // Static static jQueryInterface(config) { return this.each(function () { const data = Popover.getOrCreateInstance(this, config); if (typeof config !== 'string') { return; } if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`); } data[config](); }); } } /** * jQuery */ defineJQueryPlugin(Popover); /** * -------------------------------------------------------------------------- * Bootstrap scrollspy.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ /** * Constants */ const NAME$2 = 'scrollspy'; const DATA_KEY$2 = 'bs.scrollspy'; const EVENT_KEY$2 = `.${DATA_KEY$2}`; const DATA_API_KEY = '.data-api'; const EVENT_ACTIVATE = `activate${EVENT_KEY$2}`; const EVENT_CLICK = `click${EVENT_KEY$2}`; const EVENT_LOAD_DATA_API$1 = `load${EVENT_KEY$2}${DATA_API_KEY}`; const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'; const CLASS_NAME_ACTIVE$1 = 'active'; const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'; const SELECTOR_TARGET_LINKS = '[href]'; const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'; const SELECTOR_NAV_LINKS = '.nav-link'; const SELECTOR_NAV_ITEMS = '.nav-item'; const SELECTOR_LIST_ITEMS = '.list-group-item'; const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`; const SELECTOR_DROPDOWN = '.dropdown'; const SELECTOR_DROPDOWN_TOGGLE$1 = '.dropdown-toggle'; const Default$1 = { offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons rootMargin: '0px 0px -25%', smoothScroll: false, target: null, threshold: [0.1, 0.5, 1] }; const DefaultType$1 = { offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons rootMargin: 'string', smoothScroll: 'boolean', target: 'element', threshold: 'array' }; /** * Class definition */ class ScrollSpy extends BaseComponent { constructor(element, config) { super(element, config); // this._element is the observablesContainer and config.target the menu links wrapper this._targetLinks = new Map(); this._observableSections = new Map(); this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element; this._activeTarget = null; this._observer = null; this._previousScrollData = { visibleEntryTop: 0, parentScrollTop: 0 }; this.refresh(); // initialize } // Getters static get Default() { return Default$1; } static get DefaultType() { return DefaultType$1; } static get NAME() { return NAME$2; } // Public refresh() { this._initializeTargetsAndObservables(); this._maybeEnableSmoothScroll(); if (this._observer) { this._observer.disconnect(); } else { this._observer = this._getNewObserver(); } for (const section of this._observableSections.values()) { this._observer.observe(section); } } dispose() { this._observer.disconnect(); super.dispose(); } // Private _configAfterMerge(config) { // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case config.target = getElement(config.target) || document.body; // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin; if (typeof config.threshold === 'string') { config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value)); } return config; } _maybeEnableSmoothScroll() { if (!this._config.smoothScroll) { return; } // unregister any previous listeners EventHandler.off(this._config.target, EVENT_CLICK); EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => { const observableSection = this._observableSections.get(event.target.hash); if (observableSection) { event.preventDefault(); const root = this._rootElement || window; const height = observableSection.offsetTop - this._element.offsetTop; if (root.scrollTo) { root.scrollTo({ top: height, behavior: 'smooth' }); return; } // Chrome 60 doesn't support `scrollTo` root.scrollTop = height; } }); } _getNewObserver() { const options = { root: this._rootElement, threshold: this._config.threshold, rootMargin: this._config.rootMargin }; return new IntersectionObserver(entries => this._observerCallback(entries), options); } // The logic of selection _observerCallback(entries) { const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`); const activate = entry => { this._previousScrollData.visibleEntryTop = entry.target.offsetTop; this._process(targetElement(entry)); }; const parentScrollTop = (this._rootElement || document.documentElement).scrollTop; const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop; this._previousScrollData.parentScrollTop = parentScrollTop; for (const entry of entries) { if (!entry.isIntersecting) { this._activeTarget = null; this._clearActiveClass(targetElement(entry)); continue; } const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop; // if we are scrolling down, pick the bigger offsetTop if (userScrollsDown && entryIsLowerThanPrevious) { activate(entry); // if parent isn't scrolled, let's keep the first visible item, breaking the iteration if (!parentScrollTop) { return; } continue; } // if we are scrolling up, pick the smallest offsetTop if (!userScrollsDown && !entryIsLowerThanPrevious) { activate(entry); } } } _initializeTargetsAndObservables() { this._targetLinks = new Map(); this._observableSections = new Map(); const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target); for (const anchor of targetLinks) { // ensure that the anchor has an id and is not disabled if (!anchor.hash || isDisabled(anchor)) { continue; } const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element); // ensure that the observableSection exists & is visible if (isVisible(observableSection)) { this._targetLinks.set(decodeURI(anchor.hash), anchor); this._observableSections.set(anchor.hash, observableSection); } } } _process(target) { if (this._activeTarget === target) { return; } this._clearActiveClass(this._config.target); this._activeTarget = target; target.classList.add(CLASS_NAME_ACTIVE$1); this._activateParents(target); EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target }); } _activateParents(target) { // Activate dropdown parents if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE$1, target.closest(SELECTOR_DROPDOWN)).classList.add(CLASS_NAME_ACTIVE$1); return; } for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) { // Set triggered links parents as active // With both