Home | History | Annotate | Download | only in webapp2_extras
      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 """
     11 import logging
     12 import time
     13 
     14 import webapp2
     15 
     16 from webapp2_extras import security
     17 from 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.
     52 default_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 
     66 class AuthError(Exception):
     67     """Base auth exception."""
     68 
     69 
     70 class InvalidAuthIdError(AuthError):
     71     """Raised when a user can't be fetched given an auth_id."""
     72 
     73 
     74 class InvalidPasswordError(AuthError):
     75     """Raised when a user password doesn't match."""
     76 
     77 
     78 class 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 
    340 class 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 
    565 def 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 
    590 def 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 
    606 def 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 
    631 def 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