Skip to content

Commit 44586d7

Browse files
committed
1. Adding Multi Factor Auth Configuration
2. Added TOTP Support for Tenant 3. Added Unit Tests for MFA Config 4. Added Unit Tests for Tenant Config with respect to MFA
1 parent 5c21b81 commit 44586d7

File tree

4 files changed

+522
-11
lines changed

4 files changed

+522
-11
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# Copyright 2023 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Firebase multifactor configuration management module.
15+
16+
This module contains functions for managing various multifactor configurations at
17+
the project and tenant level.
18+
"""
19+
from enum import Enum
20+
21+
__all__ = [
22+
'validate_keys',
23+
'MultiFactorServerConfig',
24+
'TOTPProviderConfig',
25+
'ProviderConfig',
26+
'MultiFactorConfig',
27+
]
28+
29+
30+
def validate_keys(keys, valid_keys, config_name):
31+
for key in keys:
32+
if key not in valid_keys:
33+
raise ValueError(
34+
'"{0}" is not a valid "{1}" parameter.'.format(
35+
key, config_name))
36+
37+
38+
class MultiFactorServerConfig:
39+
"""Represents multi factor configuration response received from the server and
40+
converts it to user format.
41+
"""
42+
43+
def __init__(self, data):
44+
if not isinstance(data, dict):
45+
raise ValueError(
46+
'Invalid data argument in MultiFactorConfig constructor: {0}'.format(data))
47+
self._data = data
48+
49+
@property
50+
def provider_configs(self):
51+
data = self._data.get('providerConfigs', None)
52+
if data is not None:
53+
return [self.ProviderConfigServerConfig(d) for d in data]
54+
return None
55+
56+
class ProviderConfigServerConfig:
57+
"""Represents provider configuration response received from the server and converts
58+
it to user format.
59+
"""
60+
61+
def __init__(self, data):
62+
if not isinstance(data, dict):
63+
raise ValueError(
64+
'Invalid data argument in ProviderConfig constructor: {0}'.format(data))
65+
self._data = data
66+
67+
@property
68+
def state(self):
69+
return self._data.get('state', None)
70+
71+
@property
72+
def totp_provider_config(self):
73+
data = self._data.get('totpProviderConfig', None)
74+
if data is not None:
75+
return self.TOTPProviderServerConfig(data)
76+
return None
77+
78+
class TOTPProviderServerConfig:
79+
"""Represents TOTP provider configuration response received from the server and converts
80+
it to user format.
81+
"""
82+
83+
def __init__(self, data):
84+
if not isinstance(data, dict):
85+
raise ValueError(
86+
'Invalid data argument in TOTPProviderConfig constructor: {0}'.format(data))
87+
self._data = data
88+
89+
@property
90+
def adjacent_intervals(self):
91+
return self._data.get('adjacentIntervals', None)
92+
93+
94+
class TOTPProviderConfig:
95+
"""Represents a TOTP Provider Configuration to be specified for a tenant or project."""
96+
97+
def __init__(self, adjacent_intervals: int = None):
98+
self.adjacent_intervals: int = adjacent_intervals
99+
100+
def to_dict(self) -> dict:
101+
data = {}
102+
if self.adjacent_intervals is not None:
103+
data['adjacentIntervals'] = self.adjacent_intervals
104+
return data
105+
106+
def validate(self):
107+
"""Validates a given totp_provider_config object.
108+
109+
Raises:
110+
ValueError: In case of an unsuccessful validation.
111+
"""
112+
validate_keys(
113+
keys=vars(self).keys(),
114+
valid_keys={'adjacent_intervals'},
115+
config_name='TOTPProviderConfig')
116+
if self.adjacent_intervals is not None:
117+
# Because bool types get converted to int here
118+
# pylint: disable=C0123
119+
if type(self.adjacent_intervals) is not int:
120+
raise ValueError(
121+
'totp_provider_config.adjacent_intervals must be an integer between'
122+
' 1 and 10 (inclusive).')
123+
if not 1 <= self.adjacent_intervals <= 10:
124+
raise ValueError(
125+
'totp_provider_config.adjacent_intervals must be an integer between'
126+
' 1 and 10 (inclusive).')
127+
128+
def build_server_request(self):
129+
self.validate()
130+
return self.to_dict()
131+
132+
133+
class ProviderConfig:
134+
"""Represents a provider configuration for tenant or project.
135+
Currently only TOTP can be configured"""
136+
137+
class State(Enum):
138+
ENABLED = 'ENABLED'
139+
DISABLED = 'DISABLED'
140+
141+
def __init__(self,
142+
state: State = None,
143+
totp_provider_config: TOTPProviderConfig = None):
144+
self.state: self.State = state
145+
self.totp_provider_config: TOTPProviderConfig = totp_provider_config
146+
147+
def to_dict(self) -> dict:
148+
data = {}
149+
if self.state:
150+
data['state'] = self.state.value
151+
if self.totp_provider_config:
152+
data['totpProviderConfig'] = self.totp_provider_config.to_dict()
153+
return data
154+
155+
def validate(self):
156+
"""Validates a provider_config object.
157+
158+
Raises:
159+
ValueError: In case of an unsuccessful validation.
160+
"""
161+
validate_keys(
162+
keys=vars(self).keys(),
163+
valid_keys={
164+
'state',
165+
'totp_provider_config'},
166+
config_name='ProviderConfig')
167+
if self.state is None:
168+
raise ValueError('provider_config.state must be defined.')
169+
if not isinstance(self.state, ProviderConfig.State):
170+
raise ValueError(
171+
'provider_config.state must be of type ProviderConfig.State.')
172+
if self.totp_provider_config is None:
173+
raise ValueError(
174+
'provider_config.totp_provider_config must be defined.')
175+
if not isinstance(self.totp_provider_config, TOTPProviderConfig):
176+
raise ValueError(
177+
'provider_configs.totp_provider_config must be of type TOTPProviderConfig.')
178+
179+
def build_server_request(self):
180+
self.validate()
181+
return self.to_dict()
182+
183+
184+
class MultiFactorConfig:
185+
"""Represents a multi factor configuration for tenant or project
186+
"""
187+
188+
def __init__(self,
189+
provider_configs: list[ProviderConfig] = None):
190+
self.provider_configs: list[ProviderConfig] = provider_configs
191+
192+
def to_dict(self) -> dict:
193+
data = {}
194+
if self.provider_configs is not None:
195+
data['providerConfigs'] = [d.to_dict()
196+
for d in self.provider_configs]
197+
return data
198+
199+
def validate(self):
200+
"""Validates a given multi_factor_config object.
201+
202+
Raises:
203+
ValueError: In case of an unsuccessful validation.
204+
"""
205+
validate_keys(
206+
keys=vars(self).keys(),
207+
valid_keys={'provider_configs'},
208+
config_name='MultiFactorConfig')
209+
if self.provider_configs is None:
210+
raise ValueError(
211+
'multi_factor_config.provider_configs must be specified')
212+
if not isinstance(self.provider_configs, list) or not self.provider_configs:
213+
raise ValueError(
214+
'provider_configs must be an array of type ProviderConfigs.')
215+
for provider_config in self.provider_configs:
216+
if not isinstance(provider_config, ProviderConfig):
217+
raise ValueError(
218+
'provider_configs must be an array of type ProviderConfigs.')
219+
provider_config.validate()
220+
221+
def build_server_request(self):
222+
self.validate()
223+
return self.to_dict()

firebase_admin/tenant_mgt.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from firebase_admin import _auth_utils
2929
from firebase_admin import _http_client
3030
from firebase_admin import _utils
31+
from firebase_admin.multi_factor_config_mgt import MultiFactorConfig, MultiFactorServerConfig
3132

3233

3334
_TENANT_MGT_ATTRIBUTE = '_tenant_mgt'
@@ -91,7 +92,8 @@ def get_tenant(tenant_id, app=None):
9192

9293

9394
def create_tenant(
94-
display_name, allow_password_sign_up=None, enable_email_link_sign_in=None, app=None):
95+
display_name, allow_password_sign_up=None, enable_email_link_sign_in=None,
96+
multi_factor_config: MultiFactorConfig = None, app=None):
9597
"""Creates a new tenant from the given options.
9698
9799
Args:
@@ -101,6 +103,7 @@ def create_tenant(
101103
provider (optional).
102104
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
103105
sign-in (optional). Disabling this makes the password required for email sign-in.
106+
multi_factor_config : A multi factor configuration to add to the tenant (optional).
104107
app: An App instance (optional).
105108
106109
Returns:
@@ -113,12 +116,13 @@ def create_tenant(
113116
tenant_mgt_service = _get_tenant_mgt_service(app)
114117
return tenant_mgt_service.create_tenant(
115118
display_name=display_name, allow_password_sign_up=allow_password_sign_up,
116-
enable_email_link_sign_in=enable_email_link_sign_in)
119+
enable_email_link_sign_in=enable_email_link_sign_in,
120+
multi_factor_config=multi_factor_config,)
117121

118122

119123
def update_tenant(
120124
tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None,
121-
app=None):
125+
multi_factor_config: MultiFactorConfig = None, app=None):
122126
"""Updates an existing tenant with the given options.
123127
124128
Args:
@@ -128,6 +132,7 @@ def update_tenant(
128132
provider.
129133
enable_email_link_sign_in: A boolean indicating whether to enable or disable email link
130134
sign-in. Disabling this makes the password required for email sign-in.
135+
multi_factor_config : A multi factor configuration to update for the tenant (optional).
131136
app: An App instance (optional).
132137
133138
Returns:
@@ -141,7 +146,8 @@ def update_tenant(
141146
tenant_mgt_service = _get_tenant_mgt_service(app)
142147
return tenant_mgt_service.update_tenant(
143148
tenant_id, display_name=display_name, allow_password_sign_up=allow_password_sign_up,
144-
enable_email_link_sign_in=enable_email_link_sign_in)
149+
enable_email_link_sign_in=enable_email_link_sign_in,
150+
multi_factor_config=multi_factor_config)
145151

146152

147153
def delete_tenant(tenant_id, app=None):
@@ -228,6 +234,13 @@ def allow_password_sign_up(self):
228234
def enable_email_link_sign_in(self):
229235
return self._data.get('enableEmailLinkSignin', False)
230236

237+
@property
238+
def multi_factor_config(self):
239+
data = self._data.get('mfaConfig', None)
240+
if data is not None:
241+
return MultiFactorServerConfig(data)
242+
return None
243+
231244

232245
class _TenantManagementService:
233246
"""Firebase tenant management service."""
@@ -272,7 +285,8 @@ def get_tenant(self, tenant_id):
272285
return Tenant(body)
273286

274287
def create_tenant(
275-
self, display_name, allow_password_sign_up=None, enable_email_link_sign_in=None):
288+
self, display_name, allow_password_sign_up=None, enable_email_link_sign_in=None,
289+
multi_factor_config: MultiFactorConfig = None):
276290
"""Creates a new tenant from the given parameters."""
277291

278292
payload = {'displayName': _validate_display_name(display_name)}
@@ -282,7 +296,11 @@ def create_tenant(
282296
if enable_email_link_sign_in is not None:
283297
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
284298
enable_email_link_sign_in, 'enableEmailLinkSignin')
285-
299+
if multi_factor_config is not None:
300+
if not isinstance(multi_factor_config, MultiFactorConfig):
301+
raise ValueError(
302+
'multi_factor_config must be of type MultiFactorConfig.')
303+
payload['mfaConfig'] = multi_factor_config.build_server_request()
286304
try:
287305
body = self.client.body('post', '/tenants', json=payload)
288306
except requests.exceptions.RequestException as error:
@@ -292,7 +310,8 @@ def create_tenant(
292310

293311
def update_tenant(
294312
self, tenant_id, display_name=None, allow_password_sign_up=None,
295-
enable_email_link_sign_in=None):
313+
enable_email_link_sign_in=None,
314+
multi_factor_config: MultiFactorConfig = None):
296315
"""Updates the specified tenant with the given parameters."""
297316
if not isinstance(tenant_id, str) or not tenant_id:
298317
raise ValueError('Tenant ID must be a non-empty string.')
@@ -306,6 +325,11 @@ def update_tenant(
306325
if enable_email_link_sign_in is not None:
307326
payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean(
308327
enable_email_link_sign_in, 'enableEmailLinkSignin')
328+
if multi_factor_config is not None:
329+
if not isinstance(multi_factor_config, MultiFactorConfig):
330+
raise ValueError(
331+
'multi_factor_config must be of type MultiFactorConfig.')
332+
payload['mfaConfig'] = multi_factor_config.build_server_request()
309333

310334
if not payload:
311335
raise ValueError('At least one parameter must be specified for update.')

0 commit comments

Comments
 (0)