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