|
| 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