Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): Add TOTP support in Project and Tenant config #682

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
44586d7
1. Adding Multi Factor Auth Configuration
pragatimodi Mar 15, 2023
b625e13
Updating list[ProviderConfig] to List[ProviderConfig]
pragatimodi Mar 15, 2023
c002232
1. Added a project_config_mgt file
pragatimodi Mar 15, 2023
2a73f41
Import changes
pragatimodi Mar 15, 2023
b5e31b1
1. Changing auth url from `v2beta1` to `v2`
pragatimodi Mar 15, 2023
7ff0089
Add new line (lint fix)
pragatimodi Mar 15, 2023
81b1fa3
`v2beta1` -> `v2`
pragatimodi Mar 15, 2023
1f5c366
corrections
pragatimodi Mar 15, 2023
7e53e61
chore: Fix pypy tests (#694)
lahirumaramba Apr 5, 2023
6a4f451
chore(auth): Update Auth API to `v2` (#691)
pragatimodi Apr 6, 2023
0684915
Add release notes to project URLs in PyPI (#679)
samueldg Apr 6, 2023
160f983
Merge branch 'master' into mfa-totp
pragatimodi May 28, 2023
8b87e10
Addressing feedback
pragatimodi Jun 12, 2023
b5b3dce
Merge branch 'master' into mfa-totp
pragatimodi Jun 12, 2023
4bc2c83
Apply suggestions from code review
pragatimodi Jun 15, 2023
e542f91
Addressing PR feedback
pragatimodi Jul 6, 2023
58fe7ef
Merge branch 'mfa-totp' of https://github.com/firebase/firebase-admin…
pragatimodi Jul 6, 2023
b709148
Apply suggestions from code review
pragatimodi Sep 27, 2023
083d670
Update firebase_admin/multi_factor_config_mgt.py
pragatimodi Sep 27, 2023
ba9d8f4
Merge branch 'master' into mfa-totp
pragatimodi Sep 27, 2023
c67e047
fix test error messages
pragatimodi Dec 20, 2023
cde81fb
Merge branch 'master' into mfa-totp
pragatimodi Dec 20, 2023
3245d6d
lint fixes
pragatimodi Dec 20, 2023
b7e9eba
fix indent
pragatimodi Dec 20, 2023
6725d41
succinct import types
pragatimodi Dec 21, 2023
021156a
Merge branch 'master' into mfa-totp
pragatimodi Jan 9, 2024
907bf2d
change copyright year
pragatimodi Jan 10, 2024
e5c4c74
Merge branch 'master' into mfa-totp
pragatimodi Jan 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions firebase_admin/multi_factor_config_mgt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# 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.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""
from enum import Enum
from typing import List

__all__ = [
'validate_keys',
'MultiFactorServerConfig',
'TOTPProviderConfig',
'ProviderConfig',
'MultiFactorConfig',
]


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 MultiFactorServerConfig:
"""Represents multi factor configuration response received from the server and
converts it to user format.
kevinthecheung marked this conversation as resolved.
Show resolved Hide resolved
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self, data):
if not isinstance(data, dict):
raise ValueError(
'Invalid data argument in MultiFactorServerConfig constructor: {0}'.format(data))
kevinthecheung marked this conversation as resolved.
Show resolved Hide resolved
self._data = data

@property
def provider_configs(self):
data = self._data.get('providerConfigs', None)
if data is not None:
return [self.ProviderServerConfig(d) for d in data]
return None

class ProviderServerConfig:
"""Represents provider configuration response received from the server and converts
it to user format.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self, data):
if not isinstance(data, dict):
raise ValueError(
'Invalid data argument in ProviderServerConfig constructor: {0}'.format(data))
self._data = data

@property
def state(self):
return self._data.get('state', None)

@property
def totp_provider_config(self):
data = self._data.get('totpProviderConfig', None)
if data is not None:
return self.TOTPProviderServerConfig(data)
return None

class TOTPProviderServerConfig:
"""Represents TOTP provider configuration response received from the server and converts
it to user format.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self, data):
if not isinstance(data, dict):
raise ValueError(
'Invalid data argument in TOTPProviderServerConfig'
' constructor: {0}'.format(data))
self._data = data

@property
def adjacent_intervals(self):
return self._data.get('adjacentIntervals', None)


class TOTPProviderConfig:
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""Represents a TOTP Provider Configuration to be specified for a tenant or project."""
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, adjacent_intervals: int = None):
self.adjacent_intervals: int = adjacent_intervals

def to_dict(self) -> dict:
data = {}
if self.adjacent_intervals is not None:
data['adjacentIntervals'] = self.adjacent_intervals
return data

def validate(self):
"""Validates a given totp_provider_config object.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

Raises:
ValueError: In case of an unsuccessful validation.
"""
validate_keys(
keys=vars(self).keys(),
valid_keys={'adjacent_intervals'},
config_name='TOTPProviderConfig')
if self.adjacent_intervals is not None:
# Because bool types get converted to int here
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
# pylint: disable=C0123
if type(self.adjacent_intervals) is not int:
raise ValueError(
'totp_provider_config.adjacent_intervals must be an integer between'
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
' 1 and 10 (inclusive).')
if not 1 <= self.adjacent_intervals <= 10:
raise ValueError(
'totp_provider_config.adjacent_intervals must be an integer between'
' 1 and 10 (inclusive).')

def build_server_request(self):
self.validate()
return self.to_dict()


class ProviderConfig:
"""Represents a provider configuration for tenant or project.
Currently only TOTP can be configured"""
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

class State(Enum):
ENABLED = 'ENABLED'
DISABLED = 'DISABLED'

def __init__(self,
state: State = None,
totp_provider_config: TOTPProviderConfig = None):
self.state: self.State = state
self.totp_provider_config: TOTPProviderConfig = totp_provider_config

def to_dict(self) -> dict:
data = {}
if self.state:
data['state'] = self.state.value
if self.totp_provider_config:
data['totpProviderConfig'] = self.totp_provider_config.to_dict()
return data

def validate(self):
"""Validates a provider_config object.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

Raises:
ValueError: In case of an unsuccessful validation.
"""
validate_keys(
keys=vars(self).keys(),
valid_keys={
'state',
'totp_provider_config'},
config_name='ProviderConfig')
if self.state is None:
raise ValueError('provider_config.state must be defined.')
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
if not isinstance(self.state, ProviderConfig.State):
raise ValueError(
'provider_config.state must be of type ProviderConfig.State.')
if self.totp_provider_config is None:
raise ValueError(
'provider_config.totp_provider_config must be defined.')
if not isinstance(self.totp_provider_config, TOTPProviderConfig):
raise ValueError(
'provider_configs.totp_provider_config must be of type TOTPProviderConfig.')

def build_server_request(self):
self.validate()
return self.to_dict()


class MultiFactorConfig:
"""Represents a multi factor configuration for tenant or project
"""
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self,
provider_configs: List[ProviderConfig] = None):
self.provider_configs: List[ProviderConfig] = provider_configs

def to_dict(self) -> dict:
data = {}
if self.provider_configs is not None:
data['providerConfigs'] = [d.to_dict()
for d in self.provider_configs]
return data

def validate(self):
"""Validates a given multi_factor_config object.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved

Raises:
ValueError: In case of an unsuccessful validation.
"""
validate_keys(
keys=vars(self).keys(),
valid_keys={'provider_configs'},
config_name='MultiFactorConfig')
if self.provider_configs is None:
raise ValueError(
'multi_factor_config.provider_configs must be specified')
if not isinstance(self.provider_configs, list) or not self.provider_configs:
raise ValueError(
'provider_configs must be an array of type ProviderConfigs.')
for provider_config in self.provider_configs:
if not isinstance(provider_config, ProviderConfig):
raise ValueError(
'provider_configs must be an array of type ProviderConfigs.')
provider_config.validate()

def build_server_request(self):
self.validate()
return self.to_dict()
134 changes: 134 additions & 0 deletions firebase_admin/project_config_mgt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# 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 project configuration management module.
kevinthecheung marked this conversation as resolved.
Show resolved Hide resolved

This module contains functions for managing various project operations like update and create
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
"""

import requests

import firebase_admin
from firebase_admin import _auth_utils
from firebase_admin import _http_client
from firebase_admin import _utils
from firebase_admin.multi_factor_config_mgt import MultiFactorConfig
from firebase_admin.multi_factor_config_mgt import MultiFactorServerConfig

_PROJECT_CONFIG_MGT_ATTRIBUTE = '_project_config_mgt'

__all__ = [
'ProjectConfig',
'get_project_config',
'update_project_config',
]


def get_project_config(app=None):
"""Gets the project config corresponding to the given project_id.
kevinthecheung marked this conversation as resolved.
Show resolved Hide resolved

Args:
app: An App instance (optional).

Returns:
Project: A project object.

Raises:
ValueError: If the project ID is None, empty or not a string.
ProjectNotFoundError: If no project exists by the given ID.
FirebaseError: If an error occurs while retrieving the project.
"""
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):
"""Update the Project Config with the given options.
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
Args:
multi_factor_config: Updated Multi Factor Authentication configuration
(optional)
pragatimodi marked this conversation as resolved.
Show resolved Hide resolved
app: An App instance (optional).
Returns:
Project: An updated ProjectConfig object.
Raises:
ValueError: If any of the given arguments are invalid.
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)


def _get_project_config_mgt_service(app):
return _utils.get_app_service(app, _PROJECT_CONFIG_MGT_ATTRIBUTE,
_ProjectConfigManagementService)

class ProjectConfig:
"""Represents a project config in an application.
"""

def __init__(self, data):
if not isinstance(data, dict):
raise ValueError(
'Invalid data argument in Project constructor: {0}'.format(data))
self._data = data

@property
def multi_factor_config(self):
data = self._data.get('mfa')
if data:
return MultiFactorServerConfig(data)
return None

class _ProjectConfigManagementService:
"""Firebase project management service."""

PROJECT_CONFIG_MGT_URL = 'https://identitytoolkit.googleapis.com/v2/projects'

def __init__(self, app):
credential = app.credential.get_credential()
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
base_url = '{0}/{1}/config'.format(
self.PROJECT_CONFIG_MGT_URL, app.project_id)
self.app = app
self.client = _http_client.JsonHttpClient(
credential=credential, base_url=base_url, headers={'X-Client-Version': version_header})

def get_project_config(self) -> ProjectConfig:
"""Gets the project config"""
try:
body = self.client.body('get', url='')
except requests.exceptions.RequestException as error:
raise _auth_utils.handle_auth_backend_error(error)
else:
return ProjectConfig(body)

def update_project_config(self, multi_factor_config: MultiFactorConfig = None) -> ProjectConfig:
"""Updates the specified project with the given parameters."""

payload = {}
if multi_factor_config is not 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 not payload:
raise ValueError(
'At least one parameter must be specified for update.')

update_mask = ','.join(_auth_utils.build_update_mask(payload))
params = 'updateMask={0}'.format(update_mask)
try:
body = self.client.body(
'patch', url='', json=payload, params=params)
except requests.exceptions.RequestException as error:
raise _auth_utils.handle_auth_backend_error(error)
else:
return ProjectConfig(body)
Loading