1# -*- coding: utf-8 -*- 2""" 3 webapp2_extras.appengine.auth.models 4 ==================================== 5 6 Auth related models. 7 8 :copyright: 2011 by tipfy.org. 9 :license: Apache Sotware License, see LICENSE for details. 10""" 11import time 12 13try: 14 from ndb import model 15except ImportError: # pragma: no cover 16 from google.appengine.ext.ndb import model 17 18from webapp2_extras import auth 19from webapp2_extras import security 20 21 22class Unique(model.Model): 23 """A model to store unique values. 24 25 The only purpose of this model is to "reserve" values that must be unique 26 within a given scope, as a workaround because datastore doesn't support 27 the concept of uniqueness for entity properties. 28 29 For example, suppose we have a model `User` with three properties that 30 must be unique across a given group: `username`, `auth_id` and `email`:: 31 32 class User(model.Model): 33 username = model.StringProperty(required=True) 34 auth_id = model.StringProperty(required=True) 35 email = model.StringProperty(required=True) 36 37 To ensure property uniqueness when creating a new `User`, we first create 38 `Unique` records for those properties, and if everything goes well we can 39 save the new `User` record:: 40 41 @classmethod 42 def create_user(cls, username, auth_id, email): 43 # Assemble the unique values for a given class and attribute scope. 44 uniques = [ 45 'User.username.%s' % username, 46 'User.auth_id.%s' % auth_id, 47 'User.email.%s' % email, 48 ] 49 50 # Create the unique username, auth_id and email. 51 success, existing = Unique.create_multi(uniques) 52 53 if success: 54 # The unique values were created, so we can save the user. 55 user = User(username=username, auth_id=auth_id, email=email) 56 user.put() 57 return user 58 else: 59 # At least one of the values is not unique. 60 # Make a list of the property names that failed. 61 props = [name.split('.', 2)[1] for name in uniques] 62 raise ValueError('Properties %r are not unique.' % props) 63 64 Based on the idea from http://goo.gl/pBQhB 65 """ 66 67 @classmethod 68 def create(cls, value): 69 """Creates a new unique value. 70 71 :param value: 72 The value to be unique, as a string. 73 74 The value should include the scope in which the value must be 75 unique (ancestor, namespace, kind and/or property name). 76 77 For example, for a unique property `email` from kind `User`, the 78 value can be `User.email:me@myself.com`. In this case `User.email` 79 is the scope, and `me@myself.com` is the value to be unique. 80 :returns: 81 True if the unique value was created, False otherwise. 82 """ 83 entity = cls(key=model.Key(cls, value)) 84 txn = lambda: entity.put() if not entity.key.get() else None 85 return model.transaction(txn) is not None 86 87 @classmethod 88 def create_multi(cls, values): 89 """Creates multiple unique values at once. 90 91 :param values: 92 A sequence of values to be unique. See :meth:`create`. 93 :returns: 94 A tuple (bool, list_of_keys). If all values were created, bool is 95 True and list_of_keys is empty. If one or more values weren't 96 created, bool is False and the list contains all the values that 97 already existed in datastore during the creation attempt. 98 """ 99 # Maybe do a preliminary check, before going for transactions? 100 # entities = model.get_multi(keys) 101 # existing = [entity.key.id() for entity in entities if entity] 102 # if existing: 103 # return False, existing 104 105 # Create all records transactionally. 106 keys = [model.Key(cls, value) for value in values] 107 entities = [cls(key=key) for key in keys] 108 func = lambda e: e.put() if not e.key.get() else None 109 created = [model.transaction(lambda: func(e)) for e in entities] 110 111 if created != keys: 112 # A poor man's "rollback": delete all recently created records. 113 model.delete_multi(k for k in created if k) 114 return False, [k.id() for k in keys if k not in created] 115 116 return True, [] 117 118 @classmethod 119 def delete_multi(cls, values): 120 """Deletes multiple unique values at once. 121 122 :param values: 123 A sequence of values to be deleted. 124 """ 125 return model.delete_multi(model.Key(cls, v) for v in values) 126 127 128class UserToken(model.Model): 129 """Stores validation tokens for users.""" 130 131 created = model.DateTimeProperty(auto_now_add=True) 132 updated = model.DateTimeProperty(auto_now=True) 133 user = model.StringProperty(required=True, indexed=False) 134 subject = model.StringProperty(required=True) 135 token = model.StringProperty(required=True) 136 137 @classmethod 138 def get_key(cls, user, subject, token): 139 """Returns a token key. 140 141 :param user: 142 User unique ID. 143 :param subject: 144 The subject of the key. Examples: 145 146 - 'auth' 147 - 'signup' 148 :param token: 149 Randomly generated token. 150 :returns: 151 ``model.Key`` containing a string id in the following format: 152 ``{user_id}.{subject}.{token}.`` 153 """ 154 return model.Key(cls, '%s.%s.%s' % (str(user), subject, token)) 155 156 @classmethod 157 def create(cls, user, subject, token=None): 158 """Creates a new token for the given user. 159 160 :param user: 161 User unique ID. 162 :param subject: 163 The subject of the key. Examples: 164 165 - 'auth' 166 - 'signup' 167 :param token: 168 Optionally an existing token may be provided. 169 If None, a random token will be generated. 170 :returns: 171 The newly created :class:`UserToken`. 172 """ 173 user = str(user) 174 token = token or security.generate_random_string(entropy=128) 175 key = cls.get_key(user, subject, token) 176 entity = cls(key=key, user=user, subject=subject, token=token) 177 entity.put() 178 return entity 179 180 @classmethod 181 def get(cls, user=None, subject=None, token=None): 182 """Fetches a user token. 183 184 :param user: 185 User unique ID. 186 :param subject: 187 The subject of the key. Examples: 188 189 - 'auth' 190 - 'signup' 191 :param token: 192 The existing token needing verified. 193 :returns: 194 A :class:`UserToken` or None if the token does not exist. 195 """ 196 if user and subject and token: 197 return cls.get_key(user, subject, token).get() 198 199 assert subject and token, \ 200 'subject and token must be provided to UserToken.get().' 201 return cls.query(cls.subject == subject, cls.token == token).get() 202 203 204class User(model.Expando): 205 """Stores user authentication credentials or authorization ids.""" 206 207 #: The model used to ensure uniqueness. 208 unique_model = Unique 209 #: The model used to store tokens. 210 token_model = UserToken 211 212 created = model.DateTimeProperty(auto_now_add=True) 213 updated = model.DateTimeProperty(auto_now=True) 214 # ID for third party authentication, e.g. 'google:username'. UNIQUE. 215 auth_ids = model.StringProperty(repeated=True) 216 # Hashed password. Not required because third party authentication 217 # doesn't use password. 218 password = model.StringProperty() 219 220 def get_id(self): 221 """Returns this user's unique ID, which can be an integer or string.""" 222 return self._key.id() 223 224 def add_auth_id(self, auth_id): 225 """A helper method to add additional auth ids to a User 226 227 :param auth_id: 228 String representing a unique id for the user. Examples: 229 230 - own:username 231 - google:username 232 :returns: 233 A tuple (boolean, info). The boolean indicates if the user 234 was saved. If creation succeeds, ``info`` is the user entity; 235 otherwise it is a list of duplicated unique properties that 236 caused creation to fail. 237 """ 238 self.auth_ids.append(auth_id) 239 unique = '%s.auth_id:%s' % (self.__class__.__name__, auth_id) 240 ok = self.unique_model.create(unique) 241 if ok: 242 self.put() 243 return True, self 244 else: 245 return False, ['auth_id'] 246 247 @classmethod 248 def get_by_auth_id(cls, auth_id): 249 """Returns a user object based on a auth_id. 250 251 :param auth_id: 252 String representing a unique id for the user. Examples: 253 254 - own:username 255 - google:username 256 :returns: 257 A user object. 258 """ 259 return cls.query(cls.auth_ids == auth_id).get() 260 261 @classmethod 262 def get_by_auth_token(cls, user_id, token): 263 """Returns a user object based on a user ID and token. 264 265 :param user_id: 266 The user_id of the requesting user. 267 :param token: 268 The token string to be verified. 269 :returns: 270 A tuple ``(User, timestamp)``, with a user object and 271 the token timestamp, or ``(None, None)`` if both were not found. 272 """ 273 token_key = cls.token_model.get_key(user_id, 'auth', token) 274 user_key = model.Key(cls, user_id) 275 # Use get_multi() to save a RPC call. 276 valid_token, user = model.get_multi([token_key, user_key]) 277 if valid_token and user: 278 timestamp = int(time.mktime(valid_token.created.timetuple())) 279 return user, timestamp 280 281 return None, None 282 283 @classmethod 284 def get_by_auth_password(cls, auth_id, password): 285 """Returns a user object, validating password. 286 287 :param auth_id: 288 Authentication id. 289 :param password: 290 Password to be checked. 291 :returns: 292 A user object, if found and password matches. 293 :raises: 294 ``auth.InvalidAuthIdError`` or ``auth.InvalidPasswordError``. 295 """ 296 user = cls.get_by_auth_id(auth_id) 297 if not user: 298 raise auth.InvalidAuthIdError() 299 300 if not security.check_password_hash(password, user.password): 301 raise auth.InvalidPasswordError() 302 303 return user 304 305 @classmethod 306 def validate_token(cls, user_id, subject, token): 307 """Checks for existence of a token, given user_id, subject and token. 308 309 :param user_id: 310 User unique ID. 311 :param subject: 312 The subject of the key. Examples: 313 314 - 'auth' 315 - 'signup' 316 :param token: 317 The token string to be validated. 318 :returns: 319 A :class:`UserToken` or None if the token does not exist. 320 """ 321 return cls.token_model.get(user=user_id, subject=subject, 322 token=token) is not None 323 324 @classmethod 325 def create_auth_token(cls, user_id): 326 """Creates a new authorization token for a given user ID. 327 328 :param user_id: 329 User unique ID. 330 :returns: 331 A string with the authorization token. 332 """ 333 return cls.token_model.create(user_id, 'auth').token 334 335 @classmethod 336 def validate_auth_token(cls, user_id, token): 337 return cls.validate_token(user_id, 'auth', token) 338 339 @classmethod 340 def delete_auth_token(cls, user_id, token): 341 """Deletes a given authorization token. 342 343 :param user_id: 344 User unique ID. 345 :param token: 346 A string with the authorization token. 347 """ 348 cls.token_model.get_key(user_id, 'auth', token).delete() 349 350 @classmethod 351 def create_signup_token(cls, user_id): 352 entity = cls.token_model.create(user_id, 'signup') 353 return entity.token 354 355 @classmethod 356 def validate_signup_token(cls, user_id, token): 357 return cls.validate_token(user_id, 'signup', token) 358 359 @classmethod 360 def delete_signup_token(cls, user_id, token): 361 cls.token_model.get_key(user_id, 'signup', token).delete() 362 363 @classmethod 364 def create_user(cls, auth_id, unique_properties=None, **user_values): 365 """Creates a new user. 366 367 :param auth_id: 368 A string that is unique to the user. Users may have multiple 369 auth ids. Example auth ids: 370 371 - own:username 372 - own:email@example.com 373 - google:username 374 - yahoo:username 375 376 The value of `auth_id` must be unique. 377 :param unique_properties: 378 Sequence of extra property names that must be unique. 379 :param user_values: 380 Keyword arguments to create a new user entity. Since the model is 381 an ``Expando``, any provided custom properties will be saved. 382 To hash a plain password, pass a keyword ``password_raw``. 383 :returns: 384 A tuple (boolean, info). The boolean indicates if the user 385 was created. If creation succeeds, ``info`` is the user entity; 386 otherwise it is a list of duplicated unique properties that 387 caused creation to fail. 388 """ 389 assert user_values.get('password') is None, \ 390 'Use password_raw instead of password to create new users.' 391 392 assert not isinstance(auth_id, list), \ 393 'Creating a user with multiple auth_ids is not allowed, ' \ 394 'please provide a single auth_id.' 395 396 if 'password_raw' in user_values: 397 user_values['password'] = security.generate_password_hash( 398 user_values.pop('password_raw'), length=12) 399 400 user_values['auth_ids'] = [auth_id] 401 user = cls(**user_values) 402 403 # Set up unique properties. 404 uniques = [('%s.auth_id:%s' % (cls.__name__, auth_id), 'auth_id')] 405 if unique_properties: 406 for name in unique_properties: 407 key = '%s.%s:%s' % (cls.__name__, name, user_values[name]) 408 uniques.append((key, name)) 409 410 ok, existing = cls.unique_model.create_multi(k for k, v in uniques) 411 if ok: 412 user.put() 413 return True, user 414 else: 415 properties = [v for k, v in uniques if k in existing] 416 return False, properties 417