Skip to content

Commit 4cfe33a

Browse files
authored
User Management API with Phone Auth Support (#49)
* Started implementing the user management API * Implementing more test cases * Fixing a python 3 test failure * Combined the auth tests into one module * Implemented the rest of the user management API * Implemented more unit tests for user management API * Updated API docs * Implemented phone number auth support * Stricter validation for arguments * Added more tests * Improved test coverage * Updated user management tests * Test cases for valid phone numbers * Updated error message * Updated create_user() and update_user() to accept kwargs instead of dicts * Updated documentation * Refactoring code by merging some redundant lines * Extract user managemnt code into a separate helper module * Using constants in test code * Fixing a typo
1 parent 508a39b commit 4cfe33a

File tree

8 files changed

+1233
-55
lines changed

8 files changed

+1233
-55
lines changed

firebase_admin/_user_mgt.py

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# Copyright 2017 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+
15+
"""Firebase user management sub module."""
16+
17+
import re
18+
19+
from google.auth import transport
20+
import requests
21+
import six
22+
from six.moves import urllib
23+
24+
import firebase_admin
25+
26+
27+
INTERNAL_ERROR = 'INTERNAL_ERROR'
28+
USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND_ERROR'
29+
USER_CREATE_ERROR = 'USER_CREATE_ERROR'
30+
USER_UPDATE_ERROR = 'USER_UPDATE_ERROR'
31+
USER_DELETE_ERROR = 'USER_DELETE_ERROR'
32+
33+
ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/'
34+
35+
36+
class _Validator(object):
37+
"""A collectoin of data validation utilities.
38+
39+
Methods provided in this class raise ValueErrors if any validations fail. Normal returns
40+
signal success.
41+
"""
42+
43+
@classmethod
44+
def validate_uid(cls, uid):
45+
if not isinstance(uid, six.string_types) or not uid or len(uid) > 128:
46+
raise ValueError(
47+
'Invalid uid: "{0}". The uid must be a non-empty string with no more than 128 '
48+
'characters.'.format(uid))
49+
50+
@classmethod
51+
def validate_email(cls, email):
52+
if not isinstance(email, six.string_types) or not email:
53+
raise ValueError(
54+
'Invalid email: "{0}". Email must be a non-empty string.'.format(email))
55+
parts = email.split('@')
56+
if len(parts) != 2 or not parts[0] or not parts[1]:
57+
raise ValueError('Malformed email address string: "{0}".'.format(email))
58+
59+
@classmethod
60+
def validate_phone(cls, phone):
61+
"""Validates the specified phone number.
62+
63+
Phone number vlidation is very lax here. Backend will enforce E.164 spec compliance, and
64+
normalize accordingly. Here we check if the number starts with + sign, and contains at
65+
least one alphanumeric character.
66+
"""
67+
if not isinstance(phone, six.string_types) or not phone:
68+
raise ValueError('Invalid phone number: "{0}". Phone number must be a non-empty '
69+
'string.'.format(phone))
70+
if not phone.startswith('+') or not re.search('[a-zA-Z0-9]', phone):
71+
raise ValueError('Invalid phone number: "{0}". Phone number must be a valid, E.164 '
72+
'compliant identifier.'.format(phone))
73+
74+
@classmethod
75+
def validate_password(cls, password):
76+
if not isinstance(password, six.string_types) or len(password) < 6:
77+
raise ValueError(
78+
'Invalid password string. Password must be a string at least 6 characters long.')
79+
80+
@classmethod
81+
def validate_email_verified(cls, email_verified):
82+
if not isinstance(email_verified, bool):
83+
raise ValueError(
84+
'Invalid email verified status: "{0}". Email verified status must be '
85+
'boolean.'.format(email_verified))
86+
87+
@classmethod
88+
def validate_display_name(cls, display_name):
89+
if not isinstance(display_name, six.string_types) or not display_name:
90+
raise ValueError(
91+
'Invalid display name: "{0}". Display name must be a non-empty '
92+
'string.'.format(display_name))
93+
94+
@classmethod
95+
def validate_photo_url(cls, photo_url):
96+
if not isinstance(photo_url, six.string_types) or not photo_url:
97+
raise ValueError(
98+
'Invalid photo URL: "{0}". Photo URL must be a non-empty '
99+
'string.'.format(photo_url))
100+
try:
101+
parsed = urllib.parse.urlparse(photo_url)
102+
if not parsed.netloc:
103+
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
104+
except Exception:
105+
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
106+
107+
@classmethod
108+
def validate_disabled(cls, disabled):
109+
if not isinstance(disabled, bool):
110+
raise ValueError(
111+
'Invalid disabled status: "{0}". Disabled status must be '
112+
'boolean.'.format(disabled))
113+
114+
@classmethod
115+
def validate_delete_list(cls, delete_attr):
116+
if not isinstance(delete_attr, list) or not delete_attr:
117+
raise ValueError(
118+
'Invalid delete list: "{0}". Delete list must be a '
119+
'non-empty list.'.format(delete_attr))
120+
121+
122+
class ApiCallError(Exception):
123+
"""Represents an Exception encountered while invoking the Firebase user management API."""
124+
125+
def __init__(self, code, message, error=None):
126+
Exception.__init__(self, message)
127+
self.code = code
128+
self.detail = error
129+
130+
131+
class UserManager(object):
132+
"""Provides methods for interacting with the Google Identity Toolkit."""
133+
134+
_VALIDATORS = {
135+
'deleteAttribute' : _Validator.validate_delete_list,
136+
'deleteProvider' : _Validator.validate_delete_list,
137+
'disabled' : _Validator.validate_disabled,
138+
'disableUser' : _Validator.validate_disabled,
139+
'displayName' : _Validator.validate_display_name,
140+
'email' : _Validator.validate_email,
141+
'emailVerified' : _Validator.validate_email_verified,
142+
'localId' : _Validator.validate_uid,
143+
'password' : _Validator.validate_password,
144+
'phoneNumber' : _Validator.validate_phone,
145+
'photoUrl' : _Validator.validate_photo_url,
146+
}
147+
148+
_CREATE_USER_FIELDS = {
149+
'uid' : 'localId',
150+
'display_name' : 'displayName',
151+
'email' : 'email',
152+
'email_verified' : 'emailVerified',
153+
'phone_number' : 'phoneNumber',
154+
'photo_url' : 'photoUrl',
155+
'password' : 'password',
156+
'disabled' : 'disabled',
157+
}
158+
159+
_UPDATE_USER_FIELDS = {
160+
'display_name' : 'displayName',
161+
'email' : 'email',
162+
'email_verified' : 'emailVerified',
163+
'phone_number' : 'phoneNumber',
164+
'photo_url' : 'photoUrl',
165+
'password' : 'password',
166+
'disabled' : 'disabled',
167+
}
168+
169+
_REMOVABLE_FIELDS = {
170+
'displayName' : 'DISPLAY_NAME',
171+
'photoUrl' : 'PHOTO_URL'
172+
}
173+
174+
def __init__(self, app):
175+
g_credential = app.credential.get_credential()
176+
session = transport.requests.AuthorizedSession(g_credential)
177+
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
178+
session.headers.update({'X-Client-Version': version_header})
179+
self._session = session
180+
181+
def get_user(self, **kwargs):
182+
"""Gets the user data corresponding to the provided key."""
183+
if 'uid' in kwargs:
184+
key, key_type = kwargs.pop('uid'), 'user ID'
185+
_Validator.validate_uid(key)
186+
payload = {'localId' : [key]}
187+
elif 'email' in kwargs:
188+
key, key_type = kwargs.pop('email'), 'email'
189+
_Validator.validate_email(key)
190+
payload = {'email' : [key]}
191+
elif 'phone_number' in kwargs:
192+
key, key_type = kwargs.pop('phone_number'), 'phone number'
193+
_Validator.validate_phone(key)
194+
payload = {'phoneNumber' : [key]}
195+
else:
196+
raise ValueError('Unsupported keyword arguments: {0}.'.format(kwargs))
197+
198+
try:
199+
response = self._request('post', 'getAccountInfo', json=payload)
200+
except requests.exceptions.RequestException as error:
201+
msg = 'Failed to get user by {0}: {1}.'.format(key_type, key)
202+
self._handle_http_error(INTERNAL_ERROR, msg, error)
203+
else:
204+
if not response or not response.get('users'):
205+
raise ApiCallError(
206+
USER_NOT_FOUND_ERROR,
207+
'No user record found for the provided {0}: {1}.'.format(key_type, key))
208+
return response['users'][0]
209+
210+
def create_user(self, **kwargs):
211+
"""Creates a new user account with the specified properties."""
212+
payload = self._init_payload('create_user', UserManager._CREATE_USER_FIELDS, **kwargs)
213+
self._validate(payload, self._VALIDATORS, 'create user')
214+
try:
215+
response = self._request('post', 'signupNewUser', json=payload)
216+
except requests.exceptions.RequestException as error:
217+
self._handle_http_error(USER_CREATE_ERROR, 'Failed to create new user.', error)
218+
else:
219+
if not response or not response.get('localId'):
220+
raise ApiCallError(USER_CREATE_ERROR, 'Failed to create new user.')
221+
return response.get('localId')
222+
223+
def update_user(self, uid, **kwargs):
224+
"""Updates an existing user account with the specified properties"""
225+
_Validator.validate_uid(uid)
226+
payload = self._init_payload('update_user', UserManager._UPDATE_USER_FIELDS, **kwargs)
227+
payload['localId'] = uid
228+
229+
remove = []
230+
for key, value in UserManager._REMOVABLE_FIELDS.items():
231+
if key in payload and payload[key] is None:
232+
remove.append(value)
233+
del payload[key]
234+
if remove:
235+
payload['deleteAttribute'] = sorted(remove)
236+
if 'phoneNumber' in payload and payload['phoneNumber'] is None:
237+
payload['deleteProvider'] = ['phone']
238+
del payload['phoneNumber']
239+
if 'disabled' in payload:
240+
payload['disableUser'] = payload['disabled']
241+
del payload['disabled']
242+
243+
self._validate(payload, self._VALIDATORS, 'update user')
244+
try:
245+
response = self._request('post', 'setAccountInfo', json=payload)
246+
except requests.exceptions.RequestException as error:
247+
self._handle_http_error(
248+
USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid), error)
249+
else:
250+
if not response or not response.get('localId'):
251+
raise ApiCallError(USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid))
252+
return response.get('localId')
253+
254+
def delete_user(self, uid):
255+
"""Deletes the user identified by the specified user ID."""
256+
_Validator.validate_uid(uid)
257+
try:
258+
response = self._request('post', 'deleteAccount', json={'localId' : [uid]})
259+
except requests.exceptions.RequestException as error:
260+
self._handle_http_error(
261+
USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid), error)
262+
else:
263+
if not response or not response.get('kind'):
264+
raise ApiCallError(USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid))
265+
266+
def _handle_http_error(self, code, msg, error):
267+
if error.response is not None:
268+
msg += '\nServer response: {0}'.format(error.response.content.decode())
269+
else:
270+
msg += '\nReason: {0}'.format(error)
271+
raise ApiCallError(code, msg, error)
272+
273+
def _init_payload(self, operation, fields, **kwargs):
274+
payload = {}
275+
for key, value in fields.items():
276+
if key in kwargs:
277+
payload[value] = kwargs.pop(key)
278+
if kwargs:
279+
unexpected_keys = ', '.join(kwargs.keys())
280+
raise ValueError(
281+
'Unsupported arguments: "{0}" in call to {1}()'.format(unexpected_keys, operation))
282+
return payload
283+
284+
def _validate(self, properties, validators, operation):
285+
for key, value in properties.items():
286+
validator = validators.get(key)
287+
if not validator:
288+
raise ValueError('Unsupported property: "{0}" in {1} call.'.format(key, operation))
289+
validator(value)
290+
291+
def _request(self, method, urlpath, **kwargs):
292+
"""Makes an HTTP call using the Python requests library.
293+
294+
Refer to http://docs.python-requests.org/en/master/api/ for more information on supported
295+
options and features.
296+
297+
Args:
298+
method: HTTP method name as a string (e.g. get, post).
299+
urlpath: URL path of the remote endpoint. This will be appended to the server's base URL.
300+
kwargs: An additional set of keyword arguments to be passed into requests API
301+
(e.g. json, params).
302+
303+
Returns:
304+
dict: The parsed JSON response.
305+
"""
306+
resp = self._session.request(method, ID_TOOLKIT_URL + urlpath, **kwargs)
307+
resp.raise_for_status()
308+
return resp.json()

0 commit comments

Comments
 (0)