• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# -*- coding: utf-8 -*-
2"""
3    webapp2_extras.auth
4    ===================
5
6    Utilities for authentication and authorization.
7
8    :copyright: 2011 by tipfy.org.
9    :license: Apache Sotware License, see LICENSE for details.
10"""
11import logging
12import time
13
14import webapp2
15
16from webapp2_extras import security
17from webapp2_extras import sessions
18
19#: Default configuration values for this module. Keys are:
20#:
21#: user_model
22#:     User model which authenticates custom users and tokens.
23#:     Can also be a string in dotted notation to be lazily imported.
24#:     Default is :class:`webapp2_extras.appengine.auth.models.User`.
25#:
26#: session_backend
27#:     Name of the session backend to be used. Default is `securecookie`.
28#:
29#: cookie_name
30#:     Name of the cookie to save the auth session. Default is `auth`.
31#:
32#: token_max_age
33#:     Number of seconds of inactivity after which an auth token is
34#:     invalidated. The same value is used to set the ``max_age`` for
35#:     persistent auth sessions. Default is 86400 * 7 * 3 (3 weeks).
36#:
37#: token_new_age
38#:     Number of seconds after which a new token is created and written to
39#:     the database, and the old one is invalidated.
40#:     Use this to limit database writes; set to None to write on all requests.
41#:     Default is 86400 (1 day).
42#:
43#: token_cache_age
44#:     Number of seconds after which a token must be checked in the database.
45#:     Use this to limit database reads; set to None to read on all requests.
46#:     Default is 3600 (1 hour).
47#:
48#: user_attributes
49#:     A list of extra user attributes to be stored in the session.
50#      The user object must provide all of them as attributes.
51#:     Default is an empty list.
52default_config = {
53    'user_model':      'webapp2_extras.appengine.auth.models.User',
54    'session_backend': 'securecookie',
55    'cookie_name':     'auth',
56    'token_max_age':   86400 * 7 * 3,
57    'token_new_age':   86400,
58    'token_cache_age': 3600,
59    'user_attributes': [],
60}
61
62#: Internal flag for anonymous users.
63_anon = object()
64
65
66class AuthError(Exception):
67    """Base auth exception."""
68
69
70class InvalidAuthIdError(AuthError):
71    """Raised when a user can't be fetched given an auth_id."""
72
73
74class InvalidPasswordError(AuthError):
75    """Raised when a user password doesn't match."""
76
77
78class AuthStore(object):
79    """Provides common utilities and configuration for :class:`Auth`."""
80
81    #: Configuration key.
82    config_key = __name__
83
84    #: Required attributes stored in a session.
85    _session_attributes = ['user_id', 'remember',
86                           'token', 'token_ts', 'cache_ts']
87
88    def __init__(self, app, config=None):
89        """Initializes the session store.
90
91        :param app:
92            A :class:`webapp2.WSGIApplication` instance.
93        :param config:
94            A dictionary of configuration values to be overridden. See
95            the available keys in :data:`default_config`.
96        """
97        self.app = app
98        # Base configuration.
99        self.config = app.config.load_config(self.config_key,
100            default_values=default_config, user_values=config)
101
102    # User data we're interested in -------------------------------------------
103
104    @webapp2.cached_property
105    def session_attributes(self):
106        """The list of attributes stored in a session.
107
108        This must be an ordered list of unique elements.
109        """
110        seen = set()
111        attrs = self._session_attributes + self.user_attributes
112        return [a for a in attrs if a not in seen and not seen.add(a)]
113
114    @webapp2.cached_property
115    def user_attributes(self):
116        """The list of attributes retrieved from the user model.
117
118        This must be an ordered list of unique elements.
119        """
120        seen = set()
121        attrs = self.config['user_attributes']
122        return [a for a in attrs if a not in seen and not seen.add(a)]
123
124    # User model related ------------------------------------------------------
125
126    @webapp2.cached_property
127    def user_model(self):
128        """Configured user model."""
129        cls = self.config['user_model']
130        if isinstance(cls, basestring):
131            cls = self.config['user_model'] = webapp2.import_string(cls)
132
133        return cls
134
135    def get_user_by_auth_password(self, auth_id, password, silent=False):
136        """Returns a user dict based on auth_id and password.
137
138        :param auth_id:
139            Authentication id.
140        :param password:
141            User password.
142        :param silent:
143            If True, raises an exception if auth_id or password are invalid.
144        :returns:
145            A dictionary with user data.
146        :raises:
147            ``InvalidAuthIdError`` or ``InvalidPasswordError``.
148        """
149        try:
150            user = self.user_model.get_by_auth_password(auth_id, password)
151            return self.user_to_dict(user)
152        except (InvalidAuthIdError, InvalidPasswordError):
153            if not silent:
154                raise
155
156            return None
157
158    def get_user_by_auth_token(self, user_id, token):
159        """Returns a user dict based on user_id and auth token.
160
161        :param user_id:
162            User id.
163        :param token:
164            Authentication token.
165        :returns:
166            A tuple ``(user_dict, token_timestamp)``. Both values can be None.
167            The token timestamp will be None if the user is invalid or it
168            is valid but the token requires renewal.
169        """
170        user, ts = self.user_model.get_by_auth_token(user_id, token)
171        return self.user_to_dict(user), ts
172
173    def create_auth_token(self, user_id):
174        """Creates a new authentication token.
175
176        :param user_id:
177            Authentication id.
178        :returns:
179            A new authentication token.
180        """
181        return self.user_model.create_auth_token(user_id)
182
183    def delete_auth_token(self, user_id, token):
184        """Deletes an authentication token.
185
186        :param user_id:
187            User id.
188        :param token:
189            Authentication token.
190        """
191        return self.user_model.delete_auth_token(user_id, token)
192
193    def user_to_dict(self, user):
194        """Returns a dictionary based on a user object.
195
196        Extra attributes to be retrieved must be set in this module's
197        configuration.
198
199        :param user:
200            User object: an instance the custom user model.
201        :returns:
202            A dictionary with user data.
203        """
204        if not user:
205            return None
206
207        user_dict = dict((a, getattr(user, a)) for a in self.user_attributes)
208        user_dict['user_id'] = user.get_id()
209        return user_dict
210
211    # Session related ---------------------------------------------------------
212
213    def get_session(self, request):
214        """Returns an auth session.
215
216        :param request:
217            A :class:`webapp2.Request` instance.
218        :returns:
219            A session dict.
220        """
221        store = sessions.get_store(request=request)
222        return store.get_session(self.config['cookie_name'],
223                                 backend=self.config['session_backend'])
224
225    def serialize_session(self, data):
226        """Serializes values for a session.
227
228        :param data:
229            A dict with session data.
230        :returns:
231            A list with session data.
232        """
233        try:
234            assert len(data) >= len(self.session_attributes)
235            return [data.get(k) for k in self.session_attributes]
236        except AssertionError:
237            logging.warning(
238                'Invalid user data: %r. Expected attributes: %r.' %
239                (data, self.session_attributes))
240            return None
241
242    def deserialize_session(self, data):
243        """Deserializes values for a session.
244
245        :param data:
246            A list with session data.
247        :returns:
248            A dict with session data.
249        """
250        try:
251            assert len(data) >= len(self.session_attributes)
252            return dict(zip(self.session_attributes, data))
253        except AssertionError:
254            logging.warning(
255                'Invalid user data: %r. Expected attributes: %r.' %
256                (data, self.session_attributes))
257            return None
258
259    # Validators --------------------------------------------------------------
260
261    def validate_password(self, auth_id, password, silent=False):
262        """Validates a password.
263
264        Passwords are used to log-in using forms or to request auth tokens
265        from services.
266
267        :param auth_id:
268            Authentication id.
269        :param password:
270            Password to be checked.
271        :param silent:
272            If True, raises an exception if auth_id or password are invalid.
273        :returns:
274            user or None
275        :raises:
276            ``InvalidAuthIdError`` or ``InvalidPasswordError``.
277        """
278        return self.get_user_by_auth_password(auth_id, password, silent=silent)
279
280    def validate_token(self, user_id, token, token_ts=None):
281        """Validates a token.
282
283        Tokens are random strings used to authenticate temporarily. They are
284        used to validate sessions or service requests.
285
286        :param user_id:
287            User id.
288        :param token:
289            Token to be checked.
290        :param token_ts:
291            Optional token timestamp used to pre-validate the token age.
292        :returns:
293            A tuple ``(user_dict, token)``.
294        """
295        now = int(time.time())
296        delete = token_ts and ((now - token_ts) > self.config['token_max_age'])
297        create = False
298
299        if not delete:
300            # Try to fetch the user.
301            user, ts = self.get_user_by_auth_token(user_id, token)
302            if user:
303                # Now validate the real timestamp.
304                delete = (now - ts) > self.config['token_max_age']
305                create = (now - ts) > self.config['token_new_age']
306
307        if delete or create or not user:
308            if delete or create:
309                # Delete token from db.
310                self.delete_auth_token(user_id, token)
311
312                if delete:
313                    user = None
314
315            token = None
316
317        return user, token
318
319    def validate_cache_timestamp(self, cache_ts, token_ts=None):
320        """Validates a cache timestamp.
321
322        :param cache_ts:
323            Token timestamp to validate the cache age.
324        :param token_ts:
325            Token timestamp to validate the token age.
326        :returns:
327            True if it is valid, False otherwise.
328        """
329        now = int(time.time())
330        valid = (now - cache_ts) < self.config['token_cache_age']
331
332        if valid and token_ts:
333            valid2 = (now - token_ts) < self.config['token_max_age']
334            valid3 = (now - token_ts) < self.config['token_new_age']
335            valid = valid2 and valid3
336
337        return valid
338
339
340class Auth(object):
341    """Authentication provider for a single request."""
342
343    #: A :class:`webapp2.Request` instance.
344    request = None
345    #: An :class:`AuthStore` instance.
346    store = None
347    #: Cached user for the request.
348    _user = None
349
350    def __init__(self, request):
351        """Initializes the auth provider for a request.
352
353        :param request:
354            A :class:`webapp2.Request` instance.
355        """
356        self.request = request
357        self.store = get_store(app=request.app)
358
359    # Retrieving a user -------------------------------------------------------
360
361    def _user_or_none(self):
362        return self._user if self._user is not _anon else None
363
364    def get_user_by_session(self, save_session=True):
365        """Returns a user based on the current session.
366
367        :param save_session:
368            If True, saves the user in the session if authentication succeeds.
369        :returns:
370            A user dict or None.
371        """
372        if self._user is None:
373            data = self.get_session_data(pop=True)
374            if not data:
375                self._user = _anon
376            else:
377                self._user = self.get_user_by_token(
378                    user_id=data['user_id'], token=data['token'],
379                    token_ts=data['token_ts'], cache=data,
380                    cache_ts=data['cache_ts'], remember=data['remember'],
381                    save_session=save_session)
382
383        return self._user_or_none()
384
385    def get_user_by_token(self, user_id, token, token_ts=None, cache=None,
386                          cache_ts=None, remember=False, save_session=True):
387        """Returns a user based on an authentication token.
388
389        :param user_id:
390            User id.
391        :param token:
392            Authentication token.
393        :param token_ts:
394            Token timestamp, used to perform pre-validation.
395        :param cache:
396            Cached user data (from the session).
397        :param cache_ts:
398            Cache timestamp.
399        :param remember:
400            If True, saves permanent sessions.
401        :param save_session:
402            If True, saves the user in the session if authentication succeeds.
403        :returns:
404            A user dict or None.
405        """
406        if self._user is not None:
407            assert (self._user is not _anon and
408                    self._user['user_id'] == user_id and
409                    self._user['token'] == token)
410            return self._user_or_none()
411
412        if cache and cache_ts:
413            valid = self.store.validate_cache_timestamp(cache_ts, token_ts)
414            if valid:
415                self._user = cache
416            else:
417                cache_ts = None
418
419        if self._user is None:
420            # Fetch and validate the token.
421            self._user, token = self.store.validate_token(user_id, token,
422                                                          token_ts=token_ts)
423
424        if self._user is None:
425            self._user = _anon
426        elif save_session:
427            if not token:
428                token_ts = None
429
430            self.set_session(self._user, token=token, token_ts=token_ts,
431                             cache_ts=cache_ts, remember=remember)
432
433        return self._user_or_none()
434
435    def get_user_by_password(self, auth_id, password, remember=False,
436                             save_session=True, silent=False):
437        """Returns a user based on password credentials.
438
439        :param auth_id:
440            Authentication id.
441        :param password:
442            User password.
443        :param remember:
444            If True, saves permanent sessions.
445        :param save_session:
446            If True, saves the user in the session if authentication succeeds.
447        :param silent:
448            If True, raises an exception if auth_id or password are invalid.
449        :returns:
450            A user dict or None.
451        :raises:
452            ``InvalidAuthIdError`` or ``InvalidPasswordError``.
453        """
454        if save_session:
455            # During a login attempt, invalidate current session.
456            self.unset_session()
457
458        self._user = self.store.validate_password(auth_id, password,
459                                                  silent=silent)
460        if not self._user:
461            self._user = _anon
462        elif save_session:
463            # This always creates a new token with new timestamp.
464            self.set_session(self._user, remember=remember)
465
466        return self._user_or_none()
467
468    # Storing and removing user from session ----------------------------------
469
470    @webapp2.cached_property
471    def session(self):
472        """Auth session."""
473        return self.store.get_session(self.request)
474
475    def set_session(self, user, token=None, token_ts=None, cache_ts=None,
476                    remember=False, **session_args):
477        """Saves a user in the session.
478
479        :param user:
480            A dictionary with user data.
481        :param token:
482            A unique token to be persisted. If None, a new one is created.
483        :param token_ts:
484            Token timestamp. If None, a new one is created.
485        :param cache_ts:
486            Token cache timestamp. If None, a new one is created.
487        :remember:
488            If True, session is set to be persisted.
489        :param session_args:
490            Keyword arguments to set the session arguments.
491        """
492        now = int(time.time())
493        token = token or self.store.create_auth_token(user['user_id'])
494        token_ts = token_ts or now
495        cache_ts = cache_ts or now
496        if remember:
497            max_age = self.store.config['token_max_age']
498        else:
499            max_age = None
500
501        session_args.setdefault('max_age', max_age)
502        # Create a new dict or just update user?
503        # We are doing the latter, and so the user dict will always have
504        # the session metadata (token, timestamps etc). This is easier to test.
505        # But we could store only user_id and custom user attributes instead.
506        user.update({
507            'token':    token,
508            'token_ts': token_ts,
509            'cache_ts': cache_ts,
510            'remember': int(remember),
511        })
512        self.set_session_data(user, **session_args)
513        self._user = user
514
515    def unset_session(self):
516        """Removes a user from the session and invalidates the auth token."""
517        self._user = None
518        data = self.get_session_data(pop=True)
519        if data:
520            # Invalidate current token.
521            self.store.delete_auth_token(data['user_id'], data['token'])
522
523    def get_session_data(self, pop=False):
524        """Returns the session data as a dictionary.
525
526        :param pop:
527            If True, removes the session.
528        :returns:
529            A deserialized session, or None.
530        """
531        func = self.session.pop if pop else self.session.get
532        rv = func('_user', None)
533        if rv is not None:
534            data = self.store.deserialize_session(rv)
535            if data:
536                return data
537            elif not pop:
538                self.session.pop('_user', None)
539
540        return None
541
542    def set_session_data(self, data, **session_args):
543        """Sets the session data as a list.
544
545        :param data:
546            Deserialized session data.
547        :param session_args:
548            Extra arguments for the session.
549        """
550        data = self.store.serialize_session(data)
551        if data is not None:
552            self.session['_user'] = data
553            self.session.container.session_args.update(session_args)
554
555
556# Factories -------------------------------------------------------------------
557
558
559#: Key used to store :class:`AuthStore` in the app registry.
560_store_registry_key = 'webapp2_extras.auth.Auth'
561#: Key used to store :class:`Auth` in the request registry.
562_auth_registry_key = 'webapp2_extras.auth.Auth'
563
564
565def get_store(factory=AuthStore, key=_store_registry_key, app=None):
566    """Returns an instance of :class:`AuthStore` from the app registry.
567
568    It'll try to get it from the current app registry, and if it is not
569    registered it'll be instantiated and registered. A second call to this
570    function will return the same instance.
571
572    :param factory:
573        The callable used to build and register the instance if it is not yet
574        registered. The default is the class :class:`AuthStore` itself.
575    :param key:
576        The key used to store the instance in the registry. A default is used
577        if it is not set.
578    :param app:
579        A :class:`webapp2.WSGIApplication` instance used to store the instance.
580        The active app is used if it is not set.
581    """
582    app = app or webapp2.get_app()
583    store = app.registry.get(key)
584    if not store:
585        store = app.registry[key] = factory(app)
586
587    return store
588
589
590def set_store(store, key=_store_registry_key, app=None):
591    """Sets an instance of :class:`AuthStore` in the app registry.
592
593    :param store:
594        An instance of :class:`AuthStore`.
595    :param key:
596        The key used to retrieve the instance from the registry. A default
597        is used if it is not set.
598    :param request:
599        A :class:`webapp2.WSGIApplication` instance used to retrieve the
600        instance. The active app is used if it is not set.
601    """
602    app = app or webapp2.get_app()
603    app.registry[key] = store
604
605
606def get_auth(factory=Auth, key=_auth_registry_key, request=None):
607    """Returns an instance of :class:`Auth` from the request registry.
608
609    It'll try to get it from the current request registry, and if it is not
610    registered it'll be instantiated and registered. A second call to this
611    function will return the same instance.
612
613    :param factory:
614        The callable used to build and register the instance if it is not yet
615        registered. The default is the class :class:`Auth` itself.
616    :param key:
617        The key used to store the instance in the registry. A default is used
618        if it is not set.
619    :param request:
620        A :class:`webapp2.Request` instance used to store the instance. The
621        active request is used if it is not set.
622    """
623    request = request or webapp2.get_request()
624    auth = request.registry.get(key)
625    if not auth:
626        auth = request.registry[key] = factory(request)
627
628    return auth
629
630
631def set_auth(auth, key=_auth_registry_key, request=None):
632    """Sets an instance of :class:`Auth` in the request registry.
633
634    :param auth:
635        An instance of :class:`Auth`.
636    :param key:
637        The key used to retrieve the instance from the registry. A default
638        is used if it is not set.
639    :param request:
640        A :class:`webapp2.Request` instance used to retrieve the instance. The
641        active request is used if it is not set.
642    """
643    request = request or webapp2.get_request()
644    request.registry[key] = auth
645