diff --git a/firebase_admin/password_policy_config_mgt.py b/firebase_admin/password_policy_config_mgt.py new file mode 100644 index 000000000..6cfc4c113 --- /dev/null +++ b/firebase_admin/password_policy_config_mgt.py @@ -0,0 +1,240 @@ +# Copyright 2023 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Firebase multifactor configuration management module. + +This module contains functions for managing various multifactor configurations at +the project and tenant level. +""" +from enum import Enum + +__all__ = [ + 'validate_keys', + 'PasswordPolicyServerConfig', + 'PasswordPolicyConfig', + 'CustomStrengthOptionsConfig', +] + + +class PasswordPolicyServerConfig: + """Represents password policy configuration response received from the server and + converts it to user format. + """ + + def __init__(self, data): + if not isinstance(data, dict): + raise ValueError( + 'Invalid data argument in PasswordPolicyConfig constructor: {0}'.format(data)) + self._data = data + + @property + def enforcement_state(self): + return self._data.get('enforcementState', None) + + @property + def force_upgrade_on_signin(self): + return self._data.get('forceUpgradeOnSignin', None) + + @property + def constraints(self): + data = self._data.get('passwordPolicyVersions') + if data is not None: + return self.CustomStrengthOptionsServerConfig(data[0].get('customStrengthOptions')) + return None + + class CustomStrengthOptionsServerConfig: + """Represents custom strength options configuration response received from the server and + converts it to user format. + """ + + def __init__(self, data): + if not isinstance(data, dict): + raise ValueError( + 'Invalid data argument in CustomStrengthOptionsServerConfig' + ' constructor: {0}'.format(data)) + self._data = data + + @property + def require_uppercase(self): + return self._data.get('containsUppercaseCharacter', None) + + @property + def require_lowercase(self): + return self._data.get('containsLowercaseCharacter', None) + + @property + def require_non_alphanumeric(self): + return self._data.get('containsNonAlphanumericCharacter', None) + + @property + def require_numeric(self): + return self._data.get('containsNumericCharacter', None) + + @property + def min_length(self): + return self._data.get('minPasswordLength', None) + + @property + def max_length(self): + return self._data.get('maxPasswordLength', None) + + +def validate_keys(keys, valid_keys, config_name): + for key in keys: + if key not in valid_keys: + raise ValueError( + '"{0}" is not a valid "{1}" parameter.'.format( + key, config_name)) + + +class CustomStrengthOptionsConfig: + """Represents the strength attributes for the password policy""" + + def __init__( + self, + min_length: int = 6, + max_length: int = 4096, + require_uppercase: bool = False, + require_lowercase: bool = False, + require_non_alphanumeric: bool = False, + require_numeric: bool = False, + ): + self.min_length: int = min_length + self.max_length: int = max_length + self.require_uppercase: bool = require_uppercase + self.require_lowercase: bool = require_lowercase + self.require_non_alphanumeric: bool = require_non_alphanumeric + self.require_numeric: bool = require_numeric + + def to_dict(self) -> dict: + data = {} + constraints_request = {} + if self.max_length is not None: + constraints_request['maxPasswordLength'] = self.max_length + if self.min_length is not None: + constraints_request['minPasswordLength'] = self.min_length + if self.require_lowercase is not None: + constraints_request['containsLowercaseCharacter'] = self.require_lowercase + if self.require_uppercase is not None: + constraints_request['containsUppercaseCharacter'] = self.require_uppercase + if self.require_non_alphanumeric is not None: + constraints_request['containsNonAlphanumericCharacter'] = self.require_non_alphanumeric + if self.require_numeric is not None: + constraints_request['containsNumericCharacter'] = self.require_numeric + data['customStrengthOptions'] = constraints_request + return data + + def validate(self): + """Validates a constraints object. + + Raises: + ValueError: In case of an unsuccessful validation. + """ + validate_keys( + keys=vars(self).keys(), + valid_keys={ + 'require_numeric', + 'require_uppercase', + 'require_lowercase', + 'require_non_alphanumeric', + 'min_length', + 'max_length' + }, + config_name='CustomStrengthOptionsConfig') + if not isinstance(self.require_lowercase, bool): + raise ValueError('constraints.require_lowercase must be a boolean') + if not isinstance(self.require_uppercase, bool): + raise ValueError('constraints.require_uppercase must be a boolean') + if not isinstance(self.require_non_alphanumeric, bool): + raise ValueError( + 'constraints.require_non_alphanumeric must be a boolean') + if not isinstance(self.require_numeric, bool): + raise ValueError('constraints.require_numeric must be a boolean') + if not isinstance(self.min_length, int): + raise ValueError('constraints.min_length must be an integer') + if not isinstance(self.max_length, int): + raise ValueError('constraints.max_length must be an integer') + if not (self.min_length >= 6 and self.min_length <= 30): + raise ValueError('constraints.min_length must be between 6 and 30') + if not (self.max_length >= 0 and self.max_length <= 4096): + raise ValueError('constraints.max_length can be atmost 4096') + if self.min_length > self.max_length: + raise ValueError( + 'min_length must be less than or equal to max_length') + + def build_server_request(self): + self.validate() + return self.to_dict() + + +class PasswordPolicyConfig: + """Represents the configuration for the password policy on the project""" + + class EnforcementState(Enum): + ENFORCE = 'ENFORCE' + OFF = 'OFF' + + def __init__( + self, + enforcement_state: EnforcementState = None, + force_upgrade_on_signin: bool = False, + constraints: CustomStrengthOptionsConfig = None, + ): + self.enforcement_state: self.EnforcementState = enforcement_state + self.force_upgrade_on_signin: bool = force_upgrade_on_signin + self.constraints: CustomStrengthOptionsConfig = constraints + + def to_dict(self) -> dict: + data = {} + if self.enforcement_state: + data['enforcementState'] = self.enforcement_state.value + if self.force_upgrade_on_signin: + data['forceUpgradeOnSignin'] = self.force_upgrade_on_signin + if self.constraints: + data['passwordPolicyVersions'] = [self.constraints.to_dict()] + return data + + def validate(self): + """Validates a password_policy_config object. + + Raises: + ValueError: In case of an unsuccessful validation. + """ + validate_keys( + keys=vars(self).keys(), + valid_keys={ + 'enforcement_state', + 'force_upgrade_on_signin', + 'constraints'}, + config_name='PasswordPolicyConfig') + if self.enforcement_state is None: + raise ValueError( + 'password_policy_config.enforcement_state must be defined.') + if not isinstance(self.enforcement_state, PasswordPolicyConfig.EnforcementState): + raise ValueError( + 'password_policy_config.enforcement_state must be of type' + ' PasswordPolicyConfig.EnforcementState') + if not isinstance(self.force_upgrade_on_signin, bool): + raise ValueError( + 'password_policy_config.force_upgrade_on_signin must be a valid boolean') + if self.enforcement_state is self.EnforcementState.ENFORCE and self.constraints is None: + raise ValueError( + 'password_policy_config.constraints must be defined') + if not isinstance(self.constraints, CustomStrengthOptionsConfig): + raise ValueError( + 'password_policy_config.constraints must be of type CustomStrengthOptionsConfig') + self.constraints.validate() + + def build_server_request(self): + self.validate() + return self.to_dict() diff --git a/firebase_admin/project_config_mgt.py b/firebase_admin/project_config_mgt.py index df123e261..b3d15c854 100644 --- a/firebase_admin/project_config_mgt.py +++ b/firebase_admin/project_config_mgt.py @@ -24,6 +24,8 @@ from firebase_admin import _utils from firebase_admin.multi_factor_config_mgt import MultiFactorConfig from firebase_admin.multi_factor_config_mgt import MultiFactorServerConfig +from firebase_admin.password_policy_config_mgt import PasswordPolicyConfig +from firebase_admin.password_policy_config_mgt import PasswordPolicyServerConfig _PROJECT_CONFIG_MGT_ATTRIBUTE = '_project_config_mgt' @@ -52,11 +54,15 @@ def get_project_config(app=None): project_config_mgt_service = _get_project_config_mgt_service(app) return project_config_mgt_service.get_project_config() -def update_project_config(multi_factor_config: MultiFactorConfig = None, app=None): +def update_project_config( + multi_factor_config: MultiFactorConfig = None, + password_policy_config: PasswordPolicyConfig = None, + app=None): """Update the Project Config with the given options. Args: multi_factor_config: Updated Multi Factor Authentication configuration - (optional) + (optional). + password_policy_config: Updated Password Policy configuration (optional). app: An App instance (optional). Returns: Project: An updated ProjectConfig object. @@ -65,7 +71,9 @@ def update_project_config(multi_factor_config: MultiFactorConfig = None, app=Non FirebaseError: If an error occurs while updating the project. """ project_config_mgt_service = _get_project_config_mgt_service(app) - return project_config_mgt_service.update_project_config(multi_factor_config=multi_factor_config) + return project_config_mgt_service.update_project_config( + multi_factor_config=multi_factor_config, + password_policy_config=password_policy_config) def _get_project_config_mgt_service(app): @@ -89,6 +97,13 @@ def multi_factor_config(self): return MultiFactorServerConfig(data) return None + @property + def password_policy_config(self): + data = self._data.get('passwordPolicyConfig') + if data: + return PasswordPolicyServerConfig(data) + return None + class _ProjectConfigManagementService: """Firebase project management service.""" @@ -112,7 +127,10 @@ def get_project_config(self) -> ProjectConfig: else: return ProjectConfig(body) - def update_project_config(self, multi_factor_config: MultiFactorConfig = None) -> ProjectConfig: + def update_project_config( + self, + multi_factor_config: MultiFactorConfig = None, + password_policy_config: PasswordPolicyConfig = None) -> ProjectConfig: """Updates the specified project with the given parameters.""" payload = {} @@ -120,6 +138,10 @@ def update_project_config(self, multi_factor_config: MultiFactorConfig = None) - if not isinstance(multi_factor_config, MultiFactorConfig): raise ValueError('multi_factor_config must be of type MultiFactorConfig.') payload['mfa'] = multi_factor_config.build_server_request() + if password_policy_config is not None: + if not isinstance(password_policy_config, PasswordPolicyConfig): + raise ValueError('password_policy_config must be of type PasswordPolicyConfig.') + payload['passwordPolicyConfig'] = password_policy_config.build_server_request() if not payload: raise ValueError( 'At least one parameter must be specified for update.') diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index af222ff02..c1c0969ea 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -26,6 +26,7 @@ import firebase_admin from firebase_admin import auth from firebase_admin import multi_factor_config_mgt +from firebase_admin import password_policy_config_mgt from firebase_admin import _auth_utils from firebase_admin import _http_client from firebase_admin import _utils @@ -92,8 +93,13 @@ def get_tenant(tenant_id, app=None): def create_tenant( - display_name, allow_password_sign_up=None, enable_email_link_sign_in=None, - multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, app=None): + display_name, + allow_password_sign_up=None, + enable_email_link_sign_in=None, + multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, + password_policy_config: password_policy_config_mgt.PasswordPolicyConfig = None, + app=None, +): """Creates a new tenant from the given options. Args: @@ -104,6 +110,7 @@ def create_tenant( enable_email_link_sign_in: A boolean indicating whether to enable or disable email link sign-in (optional). Disabling this makes the password required for email sign-in. multi_factor_config : A multi factor configuration to add to the tenant (optional). + password_policy_config: A password policy configuration to add to the tenant (optional). app: An App instance (optional). Returns: @@ -115,14 +122,23 @@ def create_tenant( """ tenant_mgt_service = _get_tenant_mgt_service(app) return tenant_mgt_service.create_tenant( - display_name=display_name, allow_password_sign_up=allow_password_sign_up, + display_name=display_name, + allow_password_sign_up=allow_password_sign_up, enable_email_link_sign_in=enable_email_link_sign_in, - multi_factor_config=multi_factor_config,) + multi_factor_config=multi_factor_config, + password_policy_config=password_policy_config, + ) def update_tenant( - tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None, - multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, app=None): + tenant_id, + display_name=None, + allow_password_sign_up=None, + enable_email_link_sign_in=None, + multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, + password_policy_config: password_policy_config_mgt.PasswordPolicyConfig = None, + app=None, +): """Updates an existing tenant with the given options. Args: @@ -133,6 +149,7 @@ def update_tenant( enable_email_link_sign_in: A boolean indicating whether to enable or disable email link sign-in. Disabling this makes the password required for email sign-in. multi_factor_config : A multi factor configuration to update for the tenant (optional). + password_policy_config: A password policy configuration to update for the tenant (optional). app: An App instance (optional). Returns: @@ -145,9 +162,13 @@ def update_tenant( """ tenant_mgt_service = _get_tenant_mgt_service(app) return tenant_mgt_service.update_tenant( - tenant_id, display_name=display_name, allow_password_sign_up=allow_password_sign_up, + tenant_id, + display_name=display_name, + allow_password_sign_up=allow_password_sign_up, enable_email_link_sign_in=enable_email_link_sign_in, - multi_factor_config=multi_factor_config) + multi_factor_config=multi_factor_config, + password_policy_config=password_policy_config, + ) def delete_tenant(tenant_id, app=None): @@ -241,6 +262,12 @@ def multi_factor_config(self): return multi_factor_config_mgt.MultiFactorServerConfig(data) return None + @property + def password_policy_config(self): + data = self._data.get('passwordPolicyConfig', None) + if data is not None: + return password_policy_config_mgt.PasswordPolicyConfig(data) + return None class _TenantManagementService: """Firebase tenant management service.""" @@ -285,8 +312,13 @@ def get_tenant(self, tenant_id): return Tenant(body) def create_tenant( - self, display_name, allow_password_sign_up=None, enable_email_link_sign_in=None, - multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None): + self, + display_name, + allow_password_sign_up=None, + enable_email_link_sign_in=None, + multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, + password_policy_config: password_policy_config_mgt.PasswordPolicyConfig = None, + ): """Creates a new tenant from the given parameters.""" payload = {'displayName': _validate_display_name(display_name)} @@ -301,6 +333,12 @@ def create_tenant( raise ValueError( 'multi_factor_config must be of type MultiFactorConfig.') payload['mfaConfig'] = multi_factor_config.build_server_request() + if password_policy_config is not None: + if not isinstance(password_policy_config, + password_policy_config_mgt.PasswordPolicyConfig): + raise ValueError( + 'password_policy_config must be of type PasswordPolicyConfig.') + payload['passwordPolicyConfig'] = password_policy_config.build_server_request() try: body = self.client.body('post', '/tenants', json=payload) except requests.exceptions.RequestException as error: @@ -309,9 +347,14 @@ def create_tenant( return Tenant(body) def update_tenant( - self, tenant_id, display_name=None, allow_password_sign_up=None, + self, + tenant_id, + display_name=None, + allow_password_sign_up=None, enable_email_link_sign_in=None, - multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None): + multi_factor_config: multi_factor_config_mgt.MultiFactorConfig = None, + password_policy_config: password_policy_config_mgt.PasswordPolicyConfig = None, + ): """Updates the specified tenant with the given parameters.""" if not isinstance(tenant_id, str) or not tenant_id: raise ValueError('Tenant ID must be a non-empty string.') @@ -329,6 +372,12 @@ def update_tenant( if not isinstance(multi_factor_config, multi_factor_config_mgt.MultiFactorConfig): raise ValueError('multi_factor_config must be of type MultiFactorConfig.') payload['mfaConfig'] = multi_factor_config.build_server_request() + if password_policy_config is not None: + if not isinstance(password_policy_config, + password_policy_config_mgt.PasswordPolicyConfig): + raise ValueError( + 'password_policy_config must be of type PasswordPolicyConfig.') + payload['passwordPolicyConfig'] = password_policy_config.build_server_request() if not payload: raise ValueError('At least one parameter must be specified for update.') diff --git a/integration/test_project_config_mgt.py b/integration/test_project_config_mgt.py index e7c79a07d..7fd77f50e 100644 --- a/integration/test_project_config_mgt.py +++ b/integration/test_project_config_mgt.py @@ -18,6 +18,7 @@ from firebase_admin import project_config_mgt from firebase_admin import multi_factor_config_mgt +from firebase_admin import password_policy_config_mgt @pytest.fixture(scope='module') @@ -46,14 +47,28 @@ def test_update_project_config(): ) ] ) - project_config = project_config_mgt.update_project_config(multi_factor_config=mfa_object) + password_policy_object = password_policy_config_mgt.PasswordPolicyConfig( + enforcement_state=password_policy_config_mgt.PasswordPolicyConfig.EnforcementState.ENFORCE, + force_upgrade_on_signin=False, + constraints=password_policy_config_mgt.CustomStrengthOptionsConfig( + require_lowercase=True, + require_non_alphanumeric=True, + require_numeric=True, + require_uppercase=True, + max_length=30, + min_length=8 + ) + ) + project_config = project_config_mgt.update_project_config(multi_factor_config=mfa_object, password_policy_config=password_policy_object) _assert_multi_factor_config(project_config.multi_factor_config) + _assert_password_policy_config(project_config.password_policy_config) def test_get_project(): project_config = project_config_mgt.get_project_config() assert isinstance(project_config, project_config_mgt.ProjectConfig) _assert_multi_factor_config(project_config.multi_factor_config) + _assert_password_policy_config(project_config.password_policy_config) def _assert_multi_factor_config(multi_factor_config): assert isinstance(multi_factor_config, multi_factor_config_mgt.MultiFactorServerConfig) @@ -67,3 +82,24 @@ def _assert_multi_factor_config(multi_factor_config): multi_factor_config_mgt.MultiFactorServerConfig.ProviderConfigServerConfig .TOTPProviderServerConfig) assert provider_config.totp_provider_config.adjacent_intervals == 5 + +def _assert_password_policy_config(password_policy_config): + assert isinstance(password_policy_config, password_policy_config_mgt.PasswordPolicyConfig) + assert isinstance(password_policy_config.enforcement_state, password_policy_config_mgt.PasswordPolicyConfig.EnforcementState) + assert password_policy_config.enforcement_state == 'ENFORCE' + assert isinstance(password_policy_config.force_upgrade_on_signin, bool) + assert password_policy_config.force_upgrade_on_signin is False + assert isinstance(password_policy_config.constraints, password_policy_config_mgt.CustomStrengthOptionsConfig) + assert isinstance(password_policy_config.constraints.require_lowercase, bool) + assert password_policy_config.constraints.require_lowercase is True + assert isinstance(password_policy_config.constraints.require_uppercase, bool) + assert password_policy_config.constraints.require_uppercase is True + assert isinstance(password_policy_config.constraints.require_numeric, bool) + assert password_policy_config.constraints.require_numeric is True + assert isinstance(password_policy_config.constraints.require_non_alphanumeric, bool) + assert password_policy_config.constraints.require_non_alphanumeric is True + assert isinstance(password_policy_config.constraints.min_length, int) + assert password_policy_config.constraints.min_length == 8 + assert isinstance(password_policy_config.constraints.max_length, int) + assert password_policy_config.constraints.max_length == 30 +