diff --git a/.env.template b/.env.template index 2d5c5d8e..5b81d091 100644 --- a/.env.template +++ b/.env.template @@ -244,6 +244,11 @@ ## This setting applies globally to all users. # INCOMPLETE_2FA_TIME_LIMIT=3 +## Optional global master password policy, enforced for all users in addition to any +## organization policies. Uses the same JSON format as SSO_MASTER_PASSWORD_POLICY. +## Note that the Bitwarden clients do not allow a minimum length below 12 characters. +# MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + ## Disable icon downloading ## 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 diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 97f5fd49..149267c1 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -159,15 +159,20 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = Send::find_by_user(&headers.user.uuid, &conn).await.iter().map(Send::to_json).collect(); - let policies_json: Vec = + let mut policies_json: Vec = OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect(); - let policies_new_json: Vec = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn) + let mut policies_new_json: Vec = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn) .await .iter() .map(OrgPolicy::to_json) .collect(); + if let Some(global_policy) = OrgPolicy::global_master_password_policy() { + policies_json.push(global_policy.clone()); + policies_new_json.push(global_policy); + } + let domains_json = if data.exclude_domains { Value::Null } else { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 607db45b..737eba43 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2000,8 +2000,8 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) // Called during the SSO enrollment return the default policy #[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)] fn get_dummy_master_password_policy() -> JsonResult { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), + let (enabled, data) = match config_master_password_policy() { + Some(policy) => (true, policy.to_string()), _ => (false, "null".to_owned()), }; let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data); @@ -2013,8 +2013,8 @@ fn get_dummy_master_password_policy() -> JsonResult { async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, 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()), + let (enabled, data) = match config_master_password_policy() { + Some(policy) => (true, policy.to_string()), _ => (false, "null".to_owned()), }; @@ -2024,6 +2024,17 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH Ok(Json(policy.to_json())) } +// The global policy wins over the SSO one since it applies to all users +fn config_master_password_policy() -> Option { + CONFIG.master_password_policy_value().or_else(|| { + if CONFIG.sso_enabled() { + CONFIG.sso_master_password_policy_value() + } else { + None + } + }) +} + #[get("/organizations//policies/", rank = 3)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { diff --git a/src/api/mod.rs b/src/api/mod.rs index 450c2113..fbbeda6b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -78,7 +78,7 @@ impl PasswordOrOtpData { #[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")] #[derive(Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", default)] pub struct MasterPasswordPolicy { min_complexity: Option, min_length: Option, @@ -91,7 +91,7 @@ pub struct MasterPasswordPolicy { // 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 = + let mut master_password_policies: Vec = OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( &user.uuid, OrgPolicyType::MasterPassword, @@ -102,6 +102,13 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { .filter_map(|p| serde_json::from_str(&p.data).ok()) .collect(); + // The global policy applies to all users, on top of whatever their organizations require + if let Some(global_policy) = CONFIG.master_password_policy_value() + && let Ok(global_policy) = serde_json::from_value::(global_policy) + { + master_password_policies.push(global_policy); + } + let mut mpp_json = if !master_password_policies.is_empty() { json!(master_password_policies.into_iter().reduce(|acc, policy| { MasterPasswordPolicy { diff --git a/src/config.rs b/src/config.rs index 1d75d342..33fc2e39 100644 --- a/src/config.rs +++ b/src/config.rs @@ -628,6 +628,11 @@ make_config! { /// This setting applies globally to all users. incomplete_2fa_time_limit: u16, true, def, 3; + /// Master password policy |> Optional global master password policy, enforced for all users + /// in addition to any organization policies. Uses the same JSON format as the SSO master + /// password policy. Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + master_password_policy: String, true, option; + /// 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 @@ -1158,9 +1163,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { 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.as_ref())?; + validate_master_password_policy("sso_master_password_policy", cfg.sso_master_password_policy.as_ref())?; } + validate_master_password_policy("master_password_policy", cfg.master_password_policy.as_ref())?; + 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") @@ -1389,17 +1396,18 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result, +fn validate_master_password_policy( + setting_name: &str, + master_password_policy: Option<&String>, ) -> Result, Error> { - let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); + let policy = 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(Ok(_)) => err!(format!("Invalid {setting_name}: 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 ''")) + err!(format!("Invalid {setting_name} ({error}), Ensure that it's correctly escaped with ''")) } } } @@ -1771,7 +1779,13 @@ impl Config { } pub fn sso_master_password_policy_value(&self) -> Option { - validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten() + validate_master_password_policy("sso_master_password_policy", self.sso_master_password_policy().as_ref()) + .ok() + .flatten() + } + + pub fn master_password_policy_value(&self) -> Option { + validate_master_password_policy("master_password_policy", self.master_password_policy().as_ref()).ok().flatten() } pub fn sso_scopes_vec(&self) -> Vec { diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 554f51fc..25f57e15 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -29,6 +29,9 @@ pub struct OrgPolicy { pub revision_date: NaiveDateTime, } +// Fake organization id for the synthetic server-wide policy, analogous to FAKE_SSO_IDENTIFIER +pub static FAKE_GLOBAL_POLICY_ORG_ID: &str = "00000000-01DC-01DC-01DC-000000000001"; + // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs #[derive(Copy, Clone, Eq, PartialEq, num_derive::FromPrimitive)] pub enum OrgPolicyType { @@ -120,6 +123,16 @@ impl OrgPolicy { policy } + + // Synthetic policy built from the MASTER_PASSWORD_POLICY setting. Serving it alongside the + // organization policies makes the clients enforce it on password changes as well, without + // requiring an organization. + pub fn global_master_password_policy() -> Option { + CONFIG.master_password_policy_value().map(|data| { + OrgPolicy::new(FAKE_GLOBAL_POLICY_ORG_ID.into(), OrgPolicyType::MasterPassword, true, data.to_string()) + .to_json() + }) + } } /// Database methods