Skip to content

Commit 4b2e858

Browse files
authored
Implementing the import_users() API (#168)
* Refactored the validators and paylaod constructors * Fixed a lint error and other regressions * Adding initial test cases * Added more tests; Added documentation * Moved more public API types to _user_mgt and aliased them * Added hash support * Handling b64 encoding correctly for py3 * Integration tests for import users * Cleaning up UserImportRecord public API * Cleaned up the API surface; Relaxed vlidation and used duck typing instead * Facilitating more duck typing in the API * Further splitting the _user_mgt module into sub modules * refactored the UserProvider API * Added the rest of the hash algorithms * Added some documentation
1 parent 18657b9 commit 4b2e858

File tree

7 files changed

+1399
-497
lines changed

7 files changed

+1399
-497
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
- [added] A new `auth.import_users()` API for importing users into Firebase
4+
Auth in bulk.
35
- [fixed] The `db.Reference.update()` function now accepts dictionaries with
46
`None` values. This can be used to delete child keys from a reference.
57

firebase_admin/_auth_utils.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Copyright 2018 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 auth utils."""
16+
17+
import json
18+
import re
19+
20+
import six
21+
from six.moves import urllib
22+
23+
24+
MAX_CLAIMS_PAYLOAD_SIZE = 1000
25+
RESERVED_CLAIMS = set([
26+
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat',
27+
'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase',
28+
])
29+
30+
31+
def validate_uid(uid, required=False):
32+
if uid is None and not required:
33+
return None
34+
if not isinstance(uid, six.string_types) or not uid or len(uid) > 128:
35+
raise ValueError(
36+
'Invalid uid: "{0}". The uid must be a non-empty string with no more than 128 '
37+
'characters.'.format(uid))
38+
return uid
39+
40+
def validate_email(email, required=False):
41+
if email is None and not required:
42+
return None
43+
if not isinstance(email, six.string_types) or not email:
44+
raise ValueError(
45+
'Invalid email: "{0}". Email must be a non-empty string.'.format(email))
46+
parts = email.split('@')
47+
if len(parts) != 2 or not parts[0] or not parts[1]:
48+
raise ValueError('Malformed email address string: "{0}".'.format(email))
49+
return email
50+
51+
def validate_phone(phone, required=False):
52+
"""Validates the specified phone number.
53+
54+
Phone number vlidation is very lax here. Backend will enforce E.164 spec compliance, and
55+
normalize accordingly. Here we check if the number starts with + sign, and contains at
56+
least one alphanumeric character.
57+
"""
58+
if phone is None and not required:
59+
return None
60+
if not isinstance(phone, six.string_types) or not phone:
61+
raise ValueError('Invalid phone number: "{0}". Phone number must be a non-empty '
62+
'string.'.format(phone))
63+
if not phone.startswith('+') or not re.search('[a-zA-Z0-9]', phone):
64+
raise ValueError('Invalid phone number: "{0}". Phone number must be a valid, E.164 '
65+
'compliant identifier.'.format(phone))
66+
return phone
67+
68+
def validate_password(password, required=False):
69+
if password is None and not required:
70+
return None
71+
if not isinstance(password, six.string_types) or len(password) < 6:
72+
raise ValueError(
73+
'Invalid password string. Password must be a string at least 6 characters long.')
74+
return password
75+
76+
def validate_bytes(value, label, required=False):
77+
if value is None and not required:
78+
return None
79+
if not isinstance(value, six.binary_type) or not value:
80+
raise ValueError('{0} must be a non-empty byte sequence.'.format(label))
81+
return value
82+
83+
def validate_display_name(display_name, required=False):
84+
if display_name is None and not required:
85+
return None
86+
if not isinstance(display_name, six.string_types) or not display_name:
87+
raise ValueError(
88+
'Invalid display name: "{0}". Display name must be a non-empty '
89+
'string.'.format(display_name))
90+
return display_name
91+
92+
def validate_provider_id(provider_id, required=True):
93+
if provider_id is None and not required:
94+
return None
95+
if not isinstance(provider_id, six.string_types) or not provider_id:
96+
raise ValueError(
97+
'Invalid provider ID: "{0}". Provider ID must be a non-empty '
98+
'string.'.format(provider_id))
99+
return provider_id
100+
101+
def validate_photo_url(photo_url, required=False):
102+
if photo_url is None and not required:
103+
return None
104+
if not isinstance(photo_url, six.string_types) or not photo_url:
105+
raise ValueError(
106+
'Invalid photo URL: "{0}". Photo URL must be a non-empty '
107+
'string.'.format(photo_url))
108+
try:
109+
parsed = urllib.parse.urlparse(photo_url)
110+
if not parsed.netloc:
111+
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
112+
return photo_url
113+
except Exception:
114+
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
115+
116+
def validate_timestamp(timestamp, label, required=False):
117+
if timestamp is None and not required:
118+
return None
119+
if isinstance(timestamp, bool):
120+
raise ValueError('Boolean value specified as timestamp.')
121+
try:
122+
timestamp_int = int(timestamp)
123+
except TypeError:
124+
raise ValueError('Invalid type for timestamp value: {0}.'.format(timestamp))
125+
else:
126+
if timestamp_int != timestamp:
127+
raise ValueError('{0} must be a numeric value and a whole number.'.format(label))
128+
if timestamp_int <= 0:
129+
raise ValueError('{0} timestamp must be a positive interger.'.format(label))
130+
return timestamp_int
131+
132+
def validate_int(value, label, low=None, high=None):
133+
"""Validates that the given value represents an integer.
134+
135+
There are several ways to represent an integer in Python (e.g. 2, 2L, 2.0). This method allows
136+
for all such representations except for booleans. Booleans also behave like integers, but
137+
always translate to 1 and 0. Passing a boolean to an API that expects integers is most likely
138+
a developer error.
139+
"""
140+
if value is None or isinstance(value, bool):
141+
raise ValueError('Invalid type for integer value: {0}.'.format(value))
142+
try:
143+
val_int = int(value)
144+
except TypeError:
145+
raise ValueError('Invalid type for integer value: {0}.'.format(value))
146+
else:
147+
if val_int != value:
148+
# This will be True for non-numeric values like '2' and non-whole numbers like 2.5.
149+
raise ValueError('{0} must be a numeric value and a whole number.'.format(label))
150+
if low is not None and val_int < low:
151+
raise ValueError('{0} must not be smaller than {1}.'.format(label, low))
152+
if high is not None and val_int > high:
153+
raise ValueError('{0} must not be larger than {1}.'.format(label, high))
154+
return val_int
155+
156+
def validate_custom_claims(custom_claims, required=False):
157+
"""Validates the specified custom claims.
158+
159+
Custom claims must be specified as a JSON string. The string must not exceed 1000
160+
characters, and the parsed JSON payload must not contain reserved JWT claims.
161+
"""
162+
if custom_claims is None and not required:
163+
return None
164+
claims_str = str(custom_claims)
165+
if len(claims_str) > MAX_CLAIMS_PAYLOAD_SIZE:
166+
raise ValueError(
167+
'Custom claims payload must not exceed {0} characters.'.format(
168+
MAX_CLAIMS_PAYLOAD_SIZE))
169+
try:
170+
parsed = json.loads(claims_str)
171+
except Exception:
172+
raise ValueError('Failed to parse custom claims string as JSON.')
173+
174+
if not isinstance(parsed, dict):
175+
raise ValueError('Custom claims must be parseable as a JSON object.')
176+
invalid_claims = RESERVED_CLAIMS.intersection(set(parsed.keys()))
177+
if len(invalid_claims) > 1:
178+
joined = ', '.join(sorted(invalid_claims))
179+
raise ValueError('Claims "{0}" are reserved, and must not be set.'.format(joined))
180+
elif len(invalid_claims) == 1:
181+
raise ValueError(
182+
'Claim "{0}" is reserved, and must not be set.'.format(invalid_claims.pop()))
183+
return claims_str

0 commit comments

Comments
 (0)