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