diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..79ceac56 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[report] +omit = flask_oauthlib/contrib/client/* diff --git a/.gitignore b/.gitignore index 223235a0..3fbef089 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.pyc *.pyo *.egg-info +*.swp __pycache__ build develop-eggs @@ -13,3 +14,5 @@ docs/_build cover/ venv/ .tox +*.egg +.ropeproject/ diff --git a/.travis.yml b/.travis.yml index 0d4b19d0..3f0934b1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: python python: - - "2.6" - "2.7" - - "3.3" + - "3.4" + - "3.5" + - "3.6" - "pypy" script: diff --git a/AUTHORS b/AUTHORS index 741947a2..8b0469e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,3 +2,5 @@ - Randy Topliffe https://github.com/Taar - Mackenzie Blake Thompson https://github.com/flippmoke - Ib Lundgren https://github.com/ib-lundgren +- Jiangge Zhang https://github.com/tonyseek +- Stian Prestholdt https://github.com/stianpr diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..16efa24d --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,298 @@ +Changelog +========= + +Here you can see the full list of changes between each Flask-OAuthlib release. + +Version 0.9.6 +------------- + +Released on Sept 7, 2020 + +- Fix dependency conflict with requests-oauthlib +- Fix imports for Werkzeug + + +Version 0.9.5 +------------- + +Released on May 16, 2018 + +- Fix error handlers +- Update supported OAuthlib +- Add support for string type token + + +Version 0.9.4 +------------- + +Released on Jun 9, 2017 + +- Handle HTTP Basic Auth for client's access to token endpoint (#301) +- Allow having access tokens without expiration date (#311) +- Log exception traceback. (#281) + + +Version 0.9.3 +------------- + +Released on Jun 2, 2016 + +- Revert the wrong implement of non credential oauth2 require auth +- Catch all exceptions in OAuth2 providers +- Bugfix for examples, docs and other things + + +Version 0.9.2 +------------- + +Released on Nov 3, 2015 + +- Bugfix in client parse_response when body is none. +- Update contrib client by @tonyseek +- Typo fix for OAuth1 provider +- Fix OAuth2 provider on non credential clients by @Fleurer + + +Version 0.9.1 +------------- + +Released on Mar 9, 2015 + +- Improve on security. +- Fix on contrib client. + +Version 0.9.0 +------------- + +Released on Feb 3, 2015 + +- New feature for contrib client, which will become the official client in + the future via `#136`_ and `#176`_. +- Add appropriate headers when making POST request for access toke via `#169`_. +- Use a local copy of instance 'request_token_params' attribute to avoid side + effects via `#177`_. +- Some minor fixes of contrib by Hsiaoming Yang. + +.. _`#177`: https://github.com/lepture/flask-oauthlib/pull/177 +.. _`#169`: https://github.com/lepture/flask-oauthlib/pull/169 +.. _`#136`: https://github.com/lepture/flask-oauthlib/pull/136 +.. _`#176`: https://github.com/lepture/flask-oauthlib/pull/176 + + +Version 0.8.0 +------------- + +Released on Dec 3, 2014 + +.. module:: flask_oauthlib.provider.oauth2 + +- New feature for generating refresh tokens +- Add new function :meth:`OAuth2Provider.verify_request` for non vanilla Flask projects +- Some small bugfixes + + +Version 0.7.0 +------------- + +Released on Aug 20, 2014 + +.. module:: flask_oauthlib.client + +- Deprecated :meth:`OAuthRemoteApp.authorized_handler` in favor of + :meth:`OAuthRemoteApp.authorized_response`. +- Add revocation endpoint via `#131`_. +- Handle unknown exceptions in providers. +- Add PATCH method for client via `#134`_. + +.. _`#131`: https://github.com/lepture/flask-oauthlib/pull/131 +.. _`#134`: https://github.com/lepture/flask-oauthlib/pull/134 + + +Version 0.6.0 +------------- + +Released on Jul 29, 2014 + +- Compatible with OAuthLib 0.6.2 and 0.6.3 +- Add invalid_response decorator to handle invalid request +- Add error_message for OAuthLib Request. + +Version 0.5.0 +------------- + +Released on May 13, 2014 + +- Add ``contrib.apps`` module, thanks for tonyseek via `#94`_. +- Status code changed to 401 for invalid access token via `#93`_. +- **Security bug** for access token via `#92`_. +- Fix for client part, request token params for OAuth1 via `#91`_. +- **API change** for ``oauth.require_oauth`` via `#89`_. +- Fix for OAuth2 provider, support client authentication for authorization-code grant type via `#86`_. +- Fix client_credentials logic in validate_grant_type via `#85`_. +- Fix for client part, pass access token method via `#83`_. +- Fix for OAuth2 provider related to confidential client via `#82`_. + +Upgrade From 0.4.x to 0.5.0 +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +API for OAuth providers ``oauth.require_oauth`` has changed. + +Before the change, you would write code like:: + + @app.route('/api/user') + @oauth.require_oauth('email') + def user(req): + return jsonify(req.user) + +After the change, you would write code like:: + + from flask import request + + @app.route('/api/user') + @oauth.require_oauth('email') + def user(): + return jsonify(request.oauth.user) + +.. _`#94`: https://github.com/lepture/flask-oauthlib/pull/94 +.. _`#93`: https://github.com/lepture/flask-oauthlib/issues/93 +.. _`#92`: https://github.com/lepture/flask-oauthlib/issues/92 +.. _`#91`: https://github.com/lepture/flask-oauthlib/issues/91 +.. _`#89`: https://github.com/lepture/flask-oauthlib/issues/89 +.. _`#86`: https://github.com/lepture/flask-oauthlib/pull/86 +.. _`#85`: https://github.com/lepture/flask-oauthlib/pull/85 +.. _`#83`: https://github.com/lepture/flask-oauthlib/pull/83 +.. _`#82`: https://github.com/lepture/flask-oauthlib/issues/82 + +Thanks Stian Prestholdt and Jiangge Zhang. + +Version 0.4.3 +------------- + +Released on Feb 18, 2014 + +- OAuthlib released 0.6.1, which caused a bug in oauth2 provider. +- Validation for scopes on oauth2 right via `#72`_. +- Handle empty response for application/json via `#69`_. + +.. _`#69`: https://github.com/lepture/flask-oauthlib/issues/69 +.. _`#72`: https://github.com/lepture/flask-oauthlib/issues/72 + +Version 0.4.2 +------------- + +Released on Jan 3, 2014 + +Happy New Year! + +- Add param ``state`` in authorize method via `#63`_. +- Bugfix for encoding error in Python 3 via `#65`_. + +.. _`#63`: https://github.com/lepture/flask-oauthlib/issues/63 +.. _`#65`: https://github.com/lepture/flask-oauthlib/issues/65 + +Version 0.4.1 +------------- + +Released on Nov 25, 2013 + +- Add access_token on request object via `#53`_. +- Bugfix for lazy loading configuration via `#55`_. + +.. _`#53`: https://github.com/lepture/flask-oauthlib/issues/53 +.. _`#55`: https://github.com/lepture/flask-oauthlib/issues/55 + + +Version 0.4.0 +------------- + +Released on Nov 12, 2013 + +- Redesign contrib library. +- A new way for lazy loading configuration via `#51`_. +- Some bugfixes. + +.. _`#51`: https://github.com/lepture/flask-oauthlib/issues/51 + + +Version 0.3.4 +------------- + +Released on Oct 31, 2013 + +- Bugfix for client missing a string placeholder via `#49`_. +- Bugfix for client property getter via `#48`_. + +.. _`#49`: https://github.com/lepture/flask-oauthlib/issues/49 +.. _`#48`: https://github.com/lepture/flask-oauthlib/issues/48 + +Version 0.3.3 +------------- + +Released on Oct 4, 2013 + +- Support for token generator in OAuth2 Provider via `#42`_. +- Improve client part, improve test cases. +- Fix scope via `#44`_. + +.. _`#42`: https://github.com/lepture/flask-oauthlib/issues/42 +.. _`#44`: https://github.com/lepture/flask-oauthlib/issues/44 + +Version 0.3.2 +------------- + +Released on Sep 13, 2013 + +- Upgrade oauthlib to 0.6 +- A quick bugfix for request token params via `#40`_. + +.. _`#40`: https://github.com/lepture/flask-oauthlib/issues/40 + +Version 0.3.1 +------------- + +Released on Aug 22, 2013 + +- Add contrib module via `#15`_. We are still working on it, + take your own risk. +- Add example of linkedin via `#35`_. +- Compatible with new proposals of oauthlib. +- Bugfix for client part. +- Backward compatible for lower version of Flask via `#37`_. + +.. _`#15`: https://github.com/lepture/flask-oauthlib/issues/15 +.. _`#35`: https://github.com/lepture/flask-oauthlib/issues/35 +.. _`#37`: https://github.com/lepture/flask-oauthlib/issues/37 + +Version 0.3.0 +------------- + +Released on July 10, 2013. + +- OAuth1 Provider available. Documentation at :doc:`oauth1`. :) +- Add ``before_request`` and ``after_request`` via `#22`_. +- Lazy load configuration for client via `#23`_. Documentation at :ref:`lazy-configuration`. +- Python 3 compatible now. + +.. _`#22`: https://github.com/lepture/flask-oauthlib/issues/22 +.. _`#23`: https://github.com/lepture/flask-oauthlib/issues/23 + +Version 0.2.0 +------------- + +Released on June 19, 2013. + +- OAuth2 Provider available. Documentation at :doc:`oauth2`. :) +- Make client part testable. +- Change extension name of client from ``oauth-client`` to ``oauthlib.client``. + +Version 0.1.1 +------------- + +Released on May 23, 2013. + +- Fix setup.py + +Version 0.1.0 +------------- + +First public preview release on May 18, 2013. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8f060805..71af73b4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -36,7 +36,7 @@ to guarantee functionality and keep all code written in a good style. You should follow the code style. Here are some tips to make things simple: * When you cloned this repo, run ``pip install -r requirements.txt`` -* Check the code style with ``make link`` +* Check the code style with ``make lint`` * Check the test with ``make test`` * Check the test coverage with ``make coverage`` diff --git a/LICENSE b/LICENSE index 12955663..5c88f320 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013, Hsiaoming Yang. +Copyright (c) 2013 - 2014, Hsiaoming Yang. All rights reserved. diff --git a/MANIFEST.in b/MANIFEST.in index a5021c60..0a0fbdb4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include README.rst +include CHANGES.rst include LICENSE diff --git a/Makefile b/Makefile index a8a5a793..4c204137 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,25 @@ .PHONY: lint test coverage clean clean-pyc clean-build docs lint: + @which flake8 || pip install flake8 @flake8 flask_oauthlib tests test: + @which nosetests || pip install nose @nosetests -s --nologcapture coverage: + @which nosetests || pip install nose @rm -f .coverage @nosetests --with-coverage --cover-package=flask_oauthlib --cover-html -clean: clean-build clean-pyc clean-docs +clean: clean-build clean-pyc clean-docs clean-tox clean-build: @rm -fr build/ @rm -fr dist/ + @rm -fr *.egg @rm -fr *.egg-info @@ -28,5 +32,8 @@ clean-pyc: clean-docs: @rm -fr docs/_build +clean-tox: + @rm -rf .tox/ + docs: @$(MAKE) -C docs html diff --git a/README.rst b/README.rst index 36340692..f7688983 100644 --- a/README.rst +++ b/README.rst @@ -1,59 +1,85 @@ Flask-OAuthlib ============== -.. image:: https://badge.fury.io/py/Flask-OAuthlib.png - :target: http://badge.fury.io/py/Flask-OAuthlib -.. image:: https://travis-ci.org/lepture/flask-oauthlib.png?branch=master +.. image:: https://img.shields.io/badge/donate-lepture-green.svg + :target: https://lepture.com/donate + :alt: Donate lepture +.. image:: https://img.shields.io/pypi/wheel/flask-oauthlib.svg + :target: https://pypi.python.org/pypi/flask-OAuthlib/ + :alt: Wheel Status +.. image:: https://img.shields.io/pypi/v/flask-oauthlib.svg + :target: https://pypi.python.org/pypi/flask-oauthlib/ + :alt: Latest Version +.. image:: https://travis-ci.org/lepture/flask-oauthlib.svg?branch=master :target: https://travis-ci.org/lepture/flask-oauthlib -.. image:: https://coveralls.io/repos/lepture/flask-oauthlib/badge.png?branch=master + :alt: Travis CI Status +.. image:: https://coveralls.io/repos/lepture/flask-oauthlib/badge.svg?branch=master :target: https://coveralls.io/r/lepture/flask-oauthlib + :alt: Coverage Status + + +Notice +------ + +**You SHOULD use https://github.com/lepture/authlib instead**. Flask-OAuthlib is an extension to Flask that allows you to interact with remote OAuth enabled applications. On the client site, it is a replacement for Flask-OAuth. But it does more than that, it also helps you to create -oauth providers. +OAuth providers. Flask-OAuthlib relies on oauthlib_. .. _oauthlib: https://github.com/idan/oauthlib + +Sponsored by +------------ + +If you want to quickly add secure authentication to Flask, feel free to +check out Auth0's Python API SDK and free plan at `auth0.com/developers`_ +|auth0 image| + +.. _`auth0.com/developers`: https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=flask-oauthlib&utm_content=auth + +.. |auth0 image| image:: https://user-images.githubusercontent.com/290496/31718461-031a6710-b44b-11e7-80f8-7c5920c73b8f.png + :target: https://auth0.com/developers?utm_source=GHsponsor&utm_medium=GHsponsor&utm_campaign=flask-oauthlib&utm_content=auth + :alt: Coverage Status + :width: 18px + :height: 18px + + Features -------- - Support for OAuth 1.0a, 1.0, 1.1, OAuth2 client -- Friendly API (same with Flask-OAuth) +- Friendly API (same as Flask-OAuth) - Direct integration with Flask - Basic support for remote method invocation of RESTful APIs - Support OAuth1 provider with HMAC and RSA signature - Support OAuth2 provider with Bearer token -And request more features at `github issues`_. -.. _`github issues`: https://github.com/lepture/flask-oauthlib/issues +Security Reporting +------------------ + +If you found security bugs which can not be public, send me email at `me@lepture.com`. +Attachment with patch is welcome. Installation ------------ -Install flask-oauthlib is simple with pip_:: +Installing flask-oauthlib is simple with pip:: $ pip install Flask-OAuthlib -If you don't have pip installed, try with easy_install:: - - $ easy_install Flask-OAuthlib - -.. _pip: http://www.pip-installer.org/ - - -Additional Notes ----------------- - -We keep a documentation at `flask-oauthlib@readthedocs`_. +There is also a `development version `_ on GitHub. -.. _`flask-oauthlib@readthedocs`: https://flask-oauthlib.readthedocs.org -If you are only interested in client part, you can find some examples -in the ``example`` directory. +Links +----- -There is also a `development version `_ on GitHub. +- Documentation: https://flask-oauthlib.readthedocs.io +- PyPI: https://pypi.org/project/Flask-OAuthlib/ +- Client Examples: https://github.com/lepture/flask-oauthlib/tree/master/example diff --git a/artwork.svg b/artwork.svg new file mode 100644 index 00000000..17f8e667 --- /dev/null +++ b/artwork.svg @@ -0,0 +1,67 @@ + + + flask-oauthlib + Created with Sketch (http://www.bohemiancoding.com/sketch) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_static/flask-oauthlib.png b/docs/_static/flask-oauthlib.png new file mode 100644 index 00000000..67c75522 Binary files /dev/null and b/docs/_static/flask-oauthlib.png differ diff --git a/docs/_templates/brand.html b/docs/_templates/brand.html index c48d7eb7..ec1816ee 100644 --- a/docs/_templates/brand.html +++ b/docs/_templates/brand.html @@ -1,4 +1,5 @@ -

About Flask-OAuthlib

+ +

Flask-OAuthlib

Flask-OAuthlib is a replacement for Flask-OAuth. It depends on the oauthlib module.

{%- block footerinner %} diff --git a/docs/_templates/sidebarintro.html b/docs/_templates/sidebarintro.html index 83cdcd09..4c267c54 100644 --- a/docs/_templates/sidebarintro.html +++ b/docs/_templates/sidebarintro.html @@ -1,3 +1,6 @@ +

Notice

+

Flask-OAuthlib is not maintained well. Please use Authlib instead.

+

Feedback

Feedback is greatly appreciated. If you have any questions, comments, random praise, or anymous threats, shoot me an email.

diff --git a/docs/api.rst b/docs/api.rst index d2b65927..63f8bde0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -56,3 +56,15 @@ Here are APIs provided by contributors. .. autofunction:: bind_sqlalchemy .. autofunction:: bind_cache_grant + + +.. automodule:: flask_oauthlib.contrib.apps + + .. autofunction:: douban + .. autofunction:: dropbox + .. autofunction:: facebook + .. autofunction:: github + .. autofunction:: google + .. autofunction:: linkedin + .. autofunction:: twitter + .. autofunction:: weibo diff --git a/docs/changelog.rst b/docs/changelog.rst index 01fbe0c1..d9e113ec 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,114 +1 @@ -.. _changelog: - -Changelog -========= - -Here you can see the full list of changes between each Flask-OAuthlib release. - - -Version 0.4.1 -------------- - -Released on Nov 25, 2013 - -- Add access_token on request object via `#53`_. -- Bugfix for lazy loading configuration via `#55`_. - -.. _`#53`: https://github.com/lepture/flask-oauthlib/issues/53 -.. _`#55`: https://github.com/lepture/flask-oauthlib/issues/55 - - -Version 0.4.0 -------------- - -Released on Nov 12, 2013 - -- Redesign contrib library. -- A new way for lazy loading configuration via `#51`_. -- Some bugfixes. - -.. _`#51`: https://github.com/lepture/flask-oauthlib/issues/51 - - -Version 0.3.4 -------------- - -Released on Oct 31, 2013 - -- Bugfix for client missing a string placeholder via `#49`_. -- Bugfix for client property getter via `#48`_. - -.. _`#49`: https://github.com/lepture/flask-oauthlib/issues/49 -.. _`#48`: https://github.com/lepture/flask-oauthlib/issues/48 - -Version 0.3.3 -------------- - -Released on Oct 4, 2013 - -- Support for token generator in OAuth2 Provider via `#42`_. -- Improve client part, improve test cases. -- Fix scope via `#44`_. - -.. _`#42`: https://github.com/lepture/flask-oauthlib/issues/42 -.. _`#44`: https://github.com/lepture/flask-oauthlib/issues/44 - -Version 0.3.2 -------------- - -Released on Sep 13, 2013 - -- Upgrade oauthlib to 0.6 -- A quick bugfix for request token params via `#40`_. - -.. _`#40`: https://github.com/lepture/flask-oauthlib/issues/40 - -Version 0.3.1 -------------- - -Released on Aug 22, 2013 - -- Add contrib module via `#15`_. We are still working on it, - take your own risk. -- Add example of linkedin via `#35`_. -- Compatible with new proposals of oauthlib. -- Bugfix for client part. -- Backward compatible for lower version of Flask via `#37`_. - -.. _`#15`: https://github.com/lepture/flask-oauthlib/issues/15 -.. _`#35`: https://github.com/lepture/flask-oauthlib/issues/35 -.. _`#37`: https://github.com/lepture/flask-oauthlib/issues/37 - -Version 0.3.0 -------------- - -Released on July 10, 2013. - -- OAuth1 Provider available. Documentation at :doc:`oauth1`. :) -- Add ``before_request`` and ``after_request`` via `#22`_. -- Lazy load configuration for client via `#23`_. Documentation at :ref:`lazy-configuration`. -- Python 3 compatible now. - -.. _`#22`: https://github.com/lepture/flask-oauthlib/issues/22 -.. _`#23`: https://github.com/lepture/flask-oauthlib/issues/23 - -Version 0.2.0 -------------- - -Released on June 19, 2013. - -- OAuth2 Provider available. Documentation at :doc:`oauth2`. :) -- Make client part testable. -- Change extension name of client from ``oauth-client`` to ``oauthlib.client``. - -Version 0.1.1 -------------- - -Released on May 23, 2013. - -- Fix setup.py - -Version 0.1.0 -------------- - -First public preview release on May 18, 2013. +.. include:: ../CHANGES.rst diff --git a/docs/client.rst b/docs/client.rst index f2f67ecd..090cd428 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -1,21 +1,228 @@ Client ====== +.. note:: + + You SHOULD read `Flask OAuth Client `_ documentation. + The client part keeps the same API as `Flask-OAuth`_. The only changes are the imports:: from flask_oauthlib.client import OAuth +.. attention:: If you are testing the provider and the client locally, do not + make them start listening on the same address because they will + override the `session` of each other leading to strange bugs. + eg: start the provider listening on `127.0.0.1:4000` and client + listening on `localhost:4000` to avoid this problem. + .. _`Flask-OAuth`: http://pythonhosted.org/Flask-OAuth/ OAuth1 Client ------------- -The difference between OAuth1 and OAuth2 in the configuation is +The difference between OAuth1 and OAuth2 in the configuration is ``request_token_url``. In OAuth1 it is required, in OAuth2 it should be ``None``. +To connect to a remote application create a :class:`OAuth` +object and register a remote application on it using +the :meth:`~OAuth.remote_app` method:: + + from flask_oauthlib.client import OAuth + + oauth = OAuth() + the_remote_app = oauth.remote_app('the remote app', + ... + ) + +A remote application must define several URLs required by the +OAuth machinery: + +- `request_token_url` +- `access_token_url` +- `authorize_url` + +Additionally the application should define an issued `consumer_key` +and `consumer_secret`. + +You can find these values by registering your application with the remote +application you want to connect with. + +Additionally you can provide a `base_url` that is prefixed to *all* +relative URLs used in the remote app. + +For Twitter the setup would look like this:: + + twitter = oauth.remote_app('twitter', + base_url='https://api.twitter.com/1/', + request_token_url='https://api.twitter.com/oauth/request_token', + access_token_url='https://api.twitter.com/oauth/access_token', + authorize_url='https://api.twitter.com/oauth/authenticate', + consumer_key='', + consumer_secret='' + ) + +Now that the application is created one can start using the OAuth system. +One thing is missing: the tokengetter. OAuth uses a token and a secret to +figure out who is connecting to the remote application. After +authentication/authorization this information is passed to a function on +your side and it is your responsibility to remember it. + +The following rules apply: + +- It's your responsibility to store that information somewhere +- That information lives for as long as the user did not revoke the + access for your application on the remote application. If it was + revoked and the user re-enabled the application you will get different + keys, so if you store them in the database don't forget to check if + they changed in the authorization callback. +- During the authorization handshake a temporary token and secret are + issued. Your tokengetter is not used during that period. + +For a simple test application, storing that information in the session is +probably sufficient:: + + from flask import session + + @twitter.tokengetter + def get_twitter_token(token=None): + return session.get('twitter_token') + +If the token does not exist, the function must return `None`, and +otherwise return a tuple in the form ``(token, secret)``. The function +might also be passed a `token` parameter. This is user defined and can be +used to indicate another token. Imagine for instance you want to support +user and application tokens or different tokens for the same user. + +The name of the token can be passed to to the +:meth:`~OAuthRemoteApp.request` function. + +Signing in / Authorizing +------------------------ + +To sign in with Twitter or link a user account with a remote +Twitter user, simply call into +:meth:`~OAuthRemoteApp.authorize` and pass it the URL that the user should be +redirected back to. For example:: + + @app.route('/login') + def login(): + return twitter.authorize(callback=url_for('oauth_authorized', + next=request.args.get('next') or request.referrer or None)) + +If the application redirects back, the remote application can fetch +all relevant information in the `oauth_authorized` function with +:meth:`~OAuthRemoteApp.authorized_response`:: + + from flask import redirect + + @app.route('/oauth-authorized') + def oauth_authorized(): + next_url = request.args.get('next') or url_for('index') + resp = twitter.authorized_response() + if resp is None: + flash(u'You denied the request to sign in.') + return redirect(next_url) + + session['twitter_token'] = ( + resp['oauth_token'], + resp['oauth_token_secret'] + ) + session['twitter_user'] = resp['screen_name'] + + flash('You were signed in as %s' % resp['screen_name']) + return redirect(next_url) + +We store the token and the associated secret in the session so that the +tokengetter can return it. Additionally, we also store the Twitter username +that was sent back to us so that we can later display it to the user. In +larger applications it is recommended to store satellite information in a +database instead to ease debugging and more easily handle additional information +associated with the user. + +Facebook OAuth +-------------- + +For Facebook the flow is very similar to Twitter or other OAuth systems +but there is a small difference. You're not using the `request_token_url` +at all and you need to provide a scope in the `request_token_params`:: + + facebook = oauth.remote_app('facebook', + base_url='https://graph.facebook.com/', + request_token_url=None, + access_token_url='/oauth/access_token', + authorize_url='https://www.facebook.com/dialog/oauth', + consumer_key=FACEBOOK_APP_ID, + consumer_secret=FACEBOOK_APP_SECRET, + request_token_params={'scope': 'email'} + ) + +Furthermore the `callback` is mandatory for the call to +:meth:`~OAuthRemoteApp.authorize` and has to match the base URL that was +specified in the Facebook application control panel. For development you +can set it to ``localhost:5000``. + +The `APP_ID` and `APP_SECRET` can be retrieved from the Facebook app +control panel. If you don't have an application registered yet you can do +this at `facebook.com/developers `_. + +Invoking Remote Methods +----------------------- + +Now the user is signed in, but you probably want to use +OAuth to call protected remote API methods and not just sign in. For +that, the remote application object provides a +:meth:`~OAuthRemoteApp.request` method that can request information from +an OAuth protected resource. Additionally there are shortcuts like +:meth:`~OAuthRemoteApp.get` or :meth:`~OAuthRemoteApp.post` to request +data with a certain HTTP method. + +For example to create a new tweet you would call into the Twitter +application as follows:: + + resp = twitter.post('statuses/update.json', data={ + 'status': 'The text we want to tweet' + }) + if resp.status == 403: + flash('Your tweet was too long.') + else: + flash('Successfully tweeted your tweet (ID: #%s)' % resp.data['id']) + +Or to display the users' feed we can do something like this:: + + resp = twitter.get('statuses/home_timeline.json') + if resp.status == 200: + tweets = resp.data + else: + tweets = None + flash('Unable to load tweets from Twitter. Maybe out of ' + 'API calls or Twitter is overloaded.') + +Flask-OAuthlib will do its best to send data encoded in the right format to +the server and to decode it when it comes back. Incoming data is encoded +based on the `mimetype` the server sent and is stored in the +:attr:`~OAuthResponse.data` attribute. For outgoing data a default of +``'urlencode'`` is assumed. When a different format is needed, one can +specify it with the `format` parameter. The following formats are +supported: + +**Outgoing**: + - ``'urlencode'`` - form encoded data (`GET` as URL and `POST`/`PUT` as + request body) + - ``'json'`` - JSON encoded data (`POST`/`PUT` as request body) + +**Incoming** + - ``'urlencode'`` - stored as flat unicode dictionary + - ``'json'`` - decoded with JSON rules, most likely a dictionary + - ``'xml'`` - stored as elementtree element + +Unknown incoming data is stored as a string. If outgoing data of a different +format is needed, `content_type` should be specified instead and the +data provided should be an encoded string. + + Find the OAuth1 client example at `twitter.py`_. .. _`twitter.py`: https://github.com/lepture/flask-oauthlib/blob/master/example/twitter.py @@ -28,6 +235,19 @@ Find the OAuth2 client example at `github.py`_. .. _`github.py`: https://github.com/lepture/flask-oauthlib/blob/master/example/github.py +.. versionadded:: 0.4.2 + +Request state parameters in authorization can be a function:: + + from werkzeug import security + + remote = oauth.remote_app( + request_token_params={ + 'state': lambda: security.gen_salt(10) + } + ) + + .. _lazy-configuration: Lazy Configuration diff --git a/docs/conf.py b/docs/conf.py index 79eeac8d..3da329da 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ project = u'Flask-OAuthlib' import datetime -copyright = u'%i, Hsiaoming Yang' % datetime.datetime.utcnow().year +copyright = u'2013 - %i, Hsiaoming Yang' % datetime.datetime.utcnow().year # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -116,7 +116,7 @@ # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +html_logo = 'flask-oauthlib.png' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 diff --git a/docs/index.rst b/docs/index.rst index 930b075c..75bc88d4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,21 +3,27 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +.. _oauthlib: https://github.com/idan/oauthlib + Flask-OAuthlib ============== -Flask-OAuthlib is desinged as a replacement for Flask-OAuth. It depends -on the oauthlib module. +Flask-OAuthlib is designed to be a replacement for Flask-OAuth. It depends on +oauthlib_. The client part of Flask-OAuthlib shares the same API as Flask-OAuth, which is pretty and simple. +.. warning:: + + Please use https://github.com/lepture/authlib instead. + Features -------- - Support for OAuth 1.0a, 1.0, 1.1, OAuth2 client -- Friendly API (same with Flask-OAuth) +- Friendly API (same as Flask-OAuth) - Direct integration with Flask - Basic support for remote method invocation of RESTful APIs - Support OAuth1 provider with HMAC and RSA signature diff --git a/docs/install.rst b/docs/install.rst index c583f699..5c8b9bfb 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -4,23 +4,15 @@ Installation ============ This part of the documentation covers the installation of Flask-OAuthlib. -The first step to using any software package is getting it properly installed. -Distribute & Pip ----------------- +Pip +--- Installing Flask-OAuthlib is simple with `pip `_:: $ pip install Flask-OAuthlib -or, with `easy_install `_:: - - $ easy_install Flask-OAuthlib - -But, you really `shouldn't do that `_. - - Cheeseshop Mirror ----------------- diff --git a/docs/intro.rst b/docs/intro.rst index 3d2371d4..af9f4eac 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -3,43 +3,21 @@ Introduction ============ -Flask-OAuthlib is designed as a replacement for Flask-OAuth. It depends on -the oauthlib module. - +Flask-OAuthlib is designed to be a replacement for Flask-OAuth. It depends on +oauthlib. Why --- -The original `Flask-OAuth`_ is lack of maintenance, and oauthlib_ is a -promising replacement for oauth2. - -.. _`Flask-OAuth`: http://pythonhosted.org/Flask-OAuth/ -.. _oauthlib: https://github.com/idan/oauthlib - -There are lots of non-standard services that claim themself as oauth providers, -but the API are always broken. When rewrite a oauth extension for flask, -I do take them into consideration, Flask-OAuthlib does support those -non-standard services. - -Flask-OAuthlib also provide the solution for creating an oauth service. -It does support oauth1 and oauth2 (with Bearer Token) server now. - -import this ------------ - -Flask-OAuthlib was developed with a few :pep:`20` idioms in mind:: - - >>> import this - +No, you should not use this library any more. PLEASE SWITCH to Authlib_ instead. -#. Beautiful is better than ugly. -#. Explicit is better than implicit. -#. Simple is better than complex. -#. Complex is better than complicated. -#. Readability counts. +Find out documentation for: -All contributions to Flask-OAuthlib should keep these important rules in mind. +1. `Flask OAuth Client `_ +2. `Flask OAuth 1.0 Provider `_ +3. `Flask OAuth 2.0 Provider `_ +.. _Authlib: https://authlib.org/ License ------- diff --git a/docs/oauth1.rst b/docs/oauth1.rst index df56bfef..b792fabb 100644 --- a/docs/oauth1.rst +++ b/docs/oauth1.rst @@ -1,8 +1,12 @@ OAuth1 Server ============= +.. note:: + + You SHOULD read `Flask OAuth 1.0 Provider `_ documentation. + This part of documentation covers the tutorial of setting up an OAuth1 -provider. An OAuth1 server concerns how to grant the auothorization and +provider. An OAuth1 server concerns how to grant the authorization and how to protect the resource. Register an **OAuth** provider:: from flask_oauthlib.provider import OAuth1Provider @@ -21,11 +25,10 @@ Like any other Flask extensions, we can pass the application later:: To implemente the oauthorization flow, we need to understand the data model. - User (Resource Owner) --------------------- -A user, or resource owner, is usally the registered user on your site. You +A user, or resource owner, is usually the registered user on your site. You design your own user model, there is not much to say. @@ -51,22 +54,22 @@ An example of the data model in SQLAlchemy (SQLAlchemy is not required):: class Client(db.Model): # human readable name, not required - name = db.Column(db.Unicode(40)) + name = db.Column(db.String(40)) # human readable description, not required - description = db.Column(db.Unicode(400)) + description = db.Column(db.String(400)) # creator of the client, not required user_id = db.Column(db.ForeignKey('user.id')) # required if you need to support client credential - user = relationship('User') + user = db.relationship('User') - client_key = db.Column(db.Unicode(40), primary_key=True) - client_secret = db.Column(db.Unicode(55), unique=True, index=True, + client_key = db.Column(db.String(40), primary_key=True) + client_secret = db.Column(db.String(55), unique=True, index=True, nullable=False) - _realms = db.Column(db.UnicodeText) - _redirect_uris = db.Column(db.UnicodeText) + _realms = db.Column(db.Text) + _redirect_uris = db.Column(db.Text) @property def redirect_uris(self): @@ -112,21 +115,21 @@ And the all in one token example:: user_id = db.Column( db.Integer, db.ForeignKey('user.id', ondelete='CASCADE') ) - user = relationship('User') + user = db.relationship('User') client_key = db.Column( - db.Unicode(40), db.ForeignKey('client.client_key'), + db.String(40), db.ForeignKey('client.client_key'), nullable=False, ) - client = relationship('Client') + client = db.relationship('Client') - token = db.Column(db.Unicode(255), index=True, unique=True) - secret = db.Column(db.Unicode(255), nullable=False) + token = db.Column(db.String(255), index=True, unique=True) + secret = db.Column(db.String(255), nullable=False) - verifier = db.Column(db.Unicode(255)) + verifier = db.Column(db.String(255)) - redirect_uri = db.Column(db.UnicodeText) - _realms = db.Column(db.UnicodeText) + redirect_uri = db.Column(db.Text) + _realms = db.Column(db.Text) @property def realms(self): @@ -157,14 +160,14 @@ Here is an example in SQLAlchemy:: id = db.Column(db.Integer, primary_key=True) timestamp = db.Column(db.Integer) - nonce = db.Column(db.Unicode(40)) + nonce = db.Column(db.String(40)) client_key = db.Column( - db.Unicode(40), db.ForeignKey('client.client_key'), + db.String(40), db.ForeignKey('client.client_key'), nullable=False, ) - client = relationship('Client') - request_token = db.Column(db.Unicode(50)) - access_token = db.Column(db.Unicode(50)) + client = db.relationship('Client') + request_token = db.Column(db.String(50)) + access_token = db.Column(db.String(50)) Access Token @@ -186,20 +189,20 @@ The implementation in SQLAlchemy:: class AccessToken(db.Model): id = db.Column(db.Integer, primary_key=True) client_key = db.Column( - db.Unicode(40), db.ForeignKey('client.client_key'), + db.String(40), db.ForeignKey('client.client_key'), nullable=False, ) - client = relationship('Client') + client = db.relationship('Client') user_id = db.Column( db.Integer, db.ForeignKey('user.id'), ) - user = relationship('User') + user = db.relationship('User') - token = db.Column(db.Unicode(255)) - secret = db.Column(db.Unicode(255)) + token = db.Column(db.String(255)) + secret = db.Column(db.String(255)) - _realms = db.Column(db.UnicodeText) + _realms = db.Column(db.Text) @property def realms(self): @@ -443,21 +446,22 @@ Protect the resource of a user with ``require_oauth`` decorator now:: @app.route('/api/me') @oauth.require_oauth('email') - def me(request): - user = request.user + def me(): + user = request.oauth.user return jsonify(email=user.email, username=user.username) @app.route('/api/user/') @oauth.require_oauth('email') - def user(request, username): + def user(username): user = User.query.filter_by(username=username).first() return jsonify(email=user.email, username=user.username) The decorator accepts a list of realms, only the clients with the given realms can access the defined resources. -The handlers accepts an extended parameter ``request``, as we have explained -above, it contains at least: +.. versionchanged:: 0.5.0 + +The ``request`` has an additional property ``oauth``, it contains at least: - client: client model object - realms: a list of scopes @@ -465,11 +469,10 @@ above, it contains at least: - headers: headers of the request - body: body content of the request -You may find the name confused, since Flask has a ``request`` model, you can -rename it to other names, for exmaple:: - @app.route('/api/me') - @oauth.require_oauth('email', 'username') - def me(data): - user = data.user - return jsonify(email=user.email, username=user.username) +Example for OAuth 1 +------------------- + +Here is an example of OAuth 1 server: https://github.com/lepture/example-oauth1-server + +Also read this article http://lepture.com/en/2013/create-oauth-server. diff --git a/docs/oauth2.rst b/docs/oauth2.rst index d9808eb5..b0929cb8 100644 --- a/docs/oauth2.rst +++ b/docs/oauth2.rst @@ -3,7 +3,11 @@ OAuth2 Server ============= -An OAuth2 server concerns how to grant the auothorization and how to protect +.. note:: + + You SHOULD read `Flask OAuth 2.0 Provider `_ documentation. + +An OAuth2 server concerns how to grant the authorization and how to protect the resource. Register an **OAuth** provider:: from flask_oauthlib.provider import OAuth2Provider @@ -20,21 +24,21 @@ Like any other Flask extensions, we can pass the application later:: oauth.init_app(app) return app -To implemente the oauthorization flow, we need to understand the data model. +To implement the authorization flow, we need to understand the data model. User (Resource Owner) --------------------- -A user, or resource owner, is usally the registered user on your site. You -design your own user model, there is not much to say. +A user, or resource owner, is usually the registered user on your site. You +need to design your own user model. Client (Application) --------------------- -A client is the app which want to use the resource of a user. It is suggested +A client is the app which wants to use the resource of a user. It is suggested that the client is registered by a user on your site, but it is not required. -The client should contain at least these information: +The client should contain at least these properties: - client_id: A random string - client_secret: A random string @@ -49,29 +53,36 @@ But it could be better, if you implemented: - allowed_response_types: A list of response types - validate_scopes: A function to validate scopes +.. note:: + + The value of the scope parameter is expressed as a list of space- + delimited, case-sensitive strings. + + via: http://tools.ietf.org/html/rfc6749#section-3.3 + An example of the data model in SQLAlchemy (SQLAlchemy is not required):: class Client(db.Model): # human readable name, not required - name = db.Column(db.Unicode(40)) + name = db.Column(db.String(40)) # human readable description, not required - description = db.Column(db.Unicode(400)) + description = db.Column(db.String(400)) # creator of the client, not required user_id = db.Column(db.ForeignKey('user.id')) # required if you need to support client credential - user = relationship('User') + user = db.relationship('User') - client_id = db.Column(db.Unicode(40), primary_key=True) - client_secret = db.Column(db.Unicode(55), unique=True, index=True, + client_id = db.Column(db.String(40), primary_key=True) + client_secret = db.Column(db.String(55), unique=True, index=True, nullable=False) # public or confidential is_confidential = db.Column(db.Boolean) - _redirect_uris = db.Column(db.UnicodeText) - _default_scopes = db.Column(db.UnicodeText) + _redirect_uris = db.Column(db.Text) + _default_scopes = db.Column(db.Text) @property def client_type(self): @@ -100,10 +111,10 @@ Grant Token ----------- A grant token is created in the authorization flow, and will be destroyed -when the authorization finished. In this case, it would be better to store -the data in a cache, which would benefit a better performance. +when the authorization is finished. In this case, it would be better to store +the data in a cache, which leads to better performance. -A grant token should contain at least these information: +A grant token should contain at least this information: - client_id: A random string of client_id - code: A random string @@ -113,7 +124,7 @@ A grant token should contain at least these information: - redirect_uri: A URI string - delete: A function to delete itself -Also in SQLAlchemy model (would be better if it is in a cache):: +Also in an SQLAlchemy model (this should be in a cache):: class Grant(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -121,20 +132,20 @@ Also in SQLAlchemy model (would be better if it is in a cache):: user_id = db.Column( db.Integer, db.ForeignKey('user.id', ondelete='CASCADE') ) - user = relationship('User') + user = db.relationship('User') client_id = db.Column( - db.Unicode(40), db.ForeignKey('client.client_id'), + db.String(40), db.ForeignKey('client.client_id'), nullable=False, ) - client = relationship('Client') + client = db.relationship('Client') - code = db.Column(db.Unicode(255), index=True, nullable=False) + code = db.Column(db.String(255), index=True, nullable=False) - redirect_uri = db.Column(db.Unicode(255)) + redirect_uri = db.Column(db.String(255)) expires = db.Column(db.DateTime) - _scopes = db.Column(db.UnicodeText) + _scopes = db.Column(db.Text) def delete(self): db.session.delete(self) @@ -150,11 +161,11 @@ Also in SQLAlchemy model (would be better if it is in a cache):: Bearer Token ------------ -A bearer token is the final token that could be use by the client. There +A bearer token is the final token that could be used by the client. There are other token types, but bearer token is widely used. Flask-OAuthlib only -comes with bearer token. +comes with a bearer token. -A bearer token requires at least these information: +A bearer token requires at least this information: - access_token: A string token - refresh_token: A string token @@ -162,29 +173,35 @@ A bearer token requires at least these information: - scopes: A list of scopes - expires: A `datetime.datetime` object - user: The user object +- delete: A function to delete itself An example of the data model in SQLAlchemy:: class Token(db.Model): id = db.Column(db.Integer, primary_key=True) client_id = db.Column( - db.Unicode(40), db.ForeignKey('client.client_id'), + db.String(40), db.ForeignKey('client.client_id'), nullable=False, ) - client = relationship('Client') + client = db.relationship('Client') user_id = db.Column( db.Integer, db.ForeignKey('user.id') ) - user = relationship('User') + user = db.relationship('User') # currently only bearer is supported - token_type = db.Column(db.Unicode(40)) + token_type = db.Column(db.String(40)) - access_token = db.Column(db.Unicode(255), unique=True) - refresh_token = db.Column(db.Unicode(255), unique=True) + access_token = db.Column(db.String(255), unique=True) + refresh_token = db.Column(db.String(255), unique=True) expires = db.Column(db.DateTime) - _scopes = db.Column(db.UnicodeText) + _scopes = db.Column(db.Text) + + def delete(self): + db.session.delete(self) + db.session.commit() + return self @property def scopes(self): @@ -196,7 +213,7 @@ An example of the data model in SQLAlchemy:: Configuration ------------- -The oauth provider has some built-in defaults, you can change them with Flask +The Oauth provider has some built-in defaults. You can change them with Flask config: ================================== ========================================== @@ -209,21 +226,21 @@ config: ================================== ========================================== -Implements ----------- +Implementation +-------------- -The implementings of authorization flow needs two handlers, one is authorize -handler for user to confirm the grant, the other is token handler for client -to exchange/refresh access token. +The implementation of the authorization flow needs two handlers: one is the authorization +handler for the user to confirm the grant, the other is the token handler for the client +to exchange/refresh access tokens. -Before the implementing of authorize and token handler, we need to set up some -getters and setter to communicate with the database. +Before implementing the authorize and token handlers, we need to set up some +getters and setters to communicate with the database. Client getter ````````````` A client getter is required. It tells which client is sending the requests, -creating the getter with decorator:: +creating the getter with a decorator:: @oauth.clientgetter def load_client(client_id): @@ -260,15 +277,15 @@ implemented with decorators:: In the sample code, there is a ``get_current_user`` method, that will return -the current user object, you should implement it yourself. +the current user object. You should implement it yourself. -The ``request`` object is defined by ``OAuthlib``, you can get at least these +The ``request`` object is defined by ``OAuthlib``. You can get at least this much information: - client: client model object - scopes: a list of scopes - user: user model object -- redirect_uri: rediret_uri parameter +- redirect_uri: redirect_uri parameter - headers: headers of the request - body: body content of the request - state: state parameter @@ -277,8 +294,8 @@ information: Token getter and setter ``````````````````````` -Token getter and setters are required. They are used in the authorization flow -and accessing resource flow. Implemented with decorators:: +Token getter and setter are required. They are used in the authorization flow +and the accessing resource flow. They are implemented with decorators as follows:: @oauth.tokengetter def load_token(access_token=None, refresh_token=None): @@ -294,9 +311,10 @@ and accessing resource flow. Implemented with decorators:: toks = Token.query.filter_by(client_id=request.client.client_id, user_id=request.user.id) # make sure that every client has only one token connected to a user - db.session.delete(toks) + for t in toks: + db.session.delete(t) - expires_in = token.pop('expires_in') + expires_in = token.get('expires_in') expires = datetime.utcnow() + timedelta(seconds=expires_in) tok = Token( @@ -312,7 +330,7 @@ and accessing resource flow. Implemented with decorators:: db.session.commit() return tok -The getter will receive two parameters, if you don't need to support refresh +The getter will receive two parameters. If you don't need to support a refresh token, you can just load token by access token. The setter receives ``token`` and ``request`` parameters. The ``token`` is a @@ -361,7 +379,7 @@ that you implemented it this way:: confirm = request.form.get('confirm', 'no') return confirm == 'yes' -The GET request will render a page for user to confirm the grant, parameters in +The GET request will render a page for user to confirm the grant. The parameters in kwargs are: - client_id: id of the client @@ -370,17 +388,19 @@ kwargs are: - redirect_uri: redirect_uri parameter - response_type: response_type parameter -The POST request needs to return a bool value that tells whether user grantted -the access or not. +The POST request needs to return a boolean value that tells whether user granted +access or not. + +There is a ``@require_login`` decorator in the sample code. You should +implement this yourself. Here is an `example`_ by Flask documentation. -There is a ``@require_login`` decorator in the sample code, you should -implement it yourself. +.. _`example`: http://flask.pocoo.org/docs/0.12/patterns/viewdecorators/#login-required-decorator Token handler ````````````` -Token handler is a decorator for exchange/refresh access token. You don't need +Token handler is a decorator for exchanging/refreshing access token. You don't need to do much:: @app.route('/oauth/token') @@ -413,11 +433,23 @@ The authorization flow is finished, everything should be working now. and only available in password credential. +Revoke handler +`````````````` +In some cases a user may wish to revoke access given to an application and the +revoke handler makes it possible for an application to programmatically revoke +the access given to it. Also here you don't need to do much, allowing POST only +is recommended:: + + @app.route('/oauth/revoke', methods=['POST']) + @oauth.revoke_handler + def revoke_token(): pass + + Subclass way ```````````` If you are not satisfied with the decorator way of getters and setters, you can -implements them in the subclass way:: +implement them in the subclass way:: class MyProvider(OAuth2Provider): def _clientgetter(self, client_id): @@ -435,36 +467,37 @@ Protect the resource of a user with ``require_oauth`` decorator now:: @app.route('/api/me') @oauth.require_oauth('email') - def me(request): - user = request.user + def me(): + user = request.oauth.user return jsonify(email=user.email, username=user.username) @app.route('/api/user/') @oauth.require_oauth('email') - def user(request, username): + def user(username): user = User.query.filter_by(username=username).first() return jsonify(email=user.email, username=user.username) -The decorator accepts a list of scopes, only the clients with the given scopes +The decorator accepts a list of scopes and only the clients with the given scopes can access the defined resources. -The handlers accepts an extended parameter ``request``, as we have explained -above, it contains at least: +.. versionchanged:: 0.5.0 + +The ``request`` has an additional property ``oauth``, which contains at least: - client: client model object - scopes: a list of scopes - user: user model object -- redirect_uri: rediret_uri parameter +- redirect_uri: redirect_uri parameter - headers: headers of the request - body: body content of the request - state: state parameter - response_type: response_type paramter -You may find the name confused, since Flask has a ``request`` model, you can -rename it to other names, for exmaple:: +Example for OAuth 2 +------------------- - @app.route('/api/me') - @oauth.require_oauth('email', 'username') - def me(data): - user = data.user - return jsonify(email=user.email, username=user.username) +An example server (and client) can be found in the tests folder: https://github.com/lepture/flask-oauthlib/tree/master/tests/oauth2 + +Other helpful resources include: + - Another example of an OAuth 2 server: https://github.com/authlib/example-oauth2-server + - An article on how to create an OAuth server: http://lepture.com/en/2013/create-oauth-server. diff --git a/example/contrib/experiment-client/.gitignore b/example/contrib/experiment-client/.gitignore new file mode 100644 index 00000000..2c603613 --- /dev/null +++ b/example/contrib/experiment-client/.gitignore @@ -0,0 +1 @@ +dev.cfg diff --git a/example/contrib/experiment-client/douban.py b/example/contrib/experiment-client/douban.py new file mode 100644 index 00000000..3d6a6349 --- /dev/null +++ b/example/contrib/experiment-client/douban.py @@ -0,0 +1,66 @@ +from flask import Flask, url_for, session, jsonify +from flask.ext.oauthlib.contrib.client import OAuth + + +class AppConfig(object): + DEBUG = True + SECRET_KEY = 'your-secret-key' + DOUBAN_CLIENT_ID = 'your-api-key' + DOUBAN_CLIENT_SECRET = 'your-api-secret' + DOUBAN_SCOPE = [ + 'douban_basic_common', + 'shuo_basic_r', + ] + +app = Flask(__name__) +app.config.from_object(AppConfig) +app.config.from_pyfile('dev.cfg', silent=True) + +oauth = OAuth(app) +# see also https://github.com/requests/requests-oauthlib/pull/138 +douban = oauth.remote_app( + name='douban', + version='2', + endpoint_url='https://api.douban.com/', + access_token_url='https://www.douban.com/service/auth2/token', + refresh_token_url='https://www.douban.com/service/auth2/token', + authorization_url='https://www.douban.com/service/auth2/auth', + compliance_fixes='.douban:douban_compliance_fix') + + +@app.route('/') +def home(): + if obtain_douban_token(): + response = douban.get('v2/user/~me') + return jsonify(response=response.json()) + return 'Login' % url_for('oauth_douban') + + +@app.route('/auth/douban') +def oauth_douban(): + callback_uri = url_for('oauth_douban_callback', _external=True) + return douban.authorize(callback_uri) + + +@app.route('/auth/douban/callback') +def oauth_douban_callback(): + response = douban.authorized_response() + if response: + store_douban_token(response) + return repr(dict(response)) + else: + return 'T_T Denied' % (url_for('oauth_douban')) + + +@douban.tokengetter +def obtain_douban_token(): + return session.get('token') + + +@douban.tokensaver +def store_douban_token(token): + session['token'] = token + + +if __name__ == '__main__': + app.run() diff --git a/example/contrib/experiment-client/twitter.py b/example/contrib/experiment-client/twitter.py new file mode 100644 index 00000000..15e5e000 --- /dev/null +++ b/example/contrib/experiment-client/twitter.py @@ -0,0 +1,55 @@ +from flask import Flask, url_for, session, jsonify +from flask.ext.oauthlib.contrib.client import OAuth + + +class DefaultConfig(object): + DEBUG = True + SECRET_KEY = 'your-secret-key' + TWITTER_CONSUMER_KEY = 'your-api-key' + TWITTER_CONSUMER_SECRET = 'your-api-secret' + +app = Flask(__name__) +app.config.from_object(DefaultConfig) +app.config.from_pyfile('dev.cfg', silent=True) + +oauth = OAuth(app) +twitter = oauth.remote_app( + name='twitter', + version='1', + endpoint_url='https://api.twitter.com/1.1/', + request_token_url='https://api.twitter.com/oauth/request_token', + access_token_url='https://api.twitter.com/oauth/access_token', + authorization_url='https://api.twitter.com/oauth/authorize') + + +@app.route('/') +def home(): + if oauth_twitter_token(): + response = twitter.get('statuses/home_timeline.json') + return jsonify(response=response.json()) + return 'Login' % url_for('oauth_twitter') + + +@app.route('/auth/twitter') +def oauth_twitter(): + callback_uri = url_for('oauth_twitter_callback', _external=True) + return twitter.authorize(callback_uri) + + +@app.route('/auth/twitter/callback') +def oauth_twitter_callback(): + response = twitter.authorized_response() + if response: + session['token'] = (response.token, response.token_secret) + return repr(dict(response)) + else: + return 'T_T Denied' % (url_for('oauth_twitter')) + + +@twitter.tokengetter +def oauth_twitter_token(): + return session.get('token') + + +if __name__ == '__main__': + app.run() diff --git a/example/douban.py b/example/douban.py index ecafa2a0..257f9e3e 100644 --- a/example/douban.py +++ b/example/douban.py @@ -11,22 +11,20 @@ 'douban', consumer_key='0cfc3c5d9f873b1826f4b518de95b148', consumer_secret='3e209e4f9ecf6a4a', - base_url='https://api.douban.com/v2/', + base_url='https://api.douban.com/', request_token_url=None, - request_token_params={'scope': 'douban_basic_common'}, + request_token_params={'scope': 'douban_basic_common,shuo_basic_r'}, access_token_url='https://www.douban.com/service/auth2/token', authorize_url='https://www.douban.com/service/auth2/auth', access_token_method='POST', - # douban's API is a shit too!!!! we need to force parse the response - content_type='application/json', ) @app.route('/') def index(): if 'douban_token' in session: - resp = douban.get('user/~me') - return jsonify(resp.data) + resp = douban.get('shuo/v2/statuses/home_timeline') + return jsonify(status=resp.status, data=resp.data) return redirect(url_for('login')) @@ -42,8 +40,8 @@ def logout(): @app.route('/login/authorized') -@douban.authorized_handler -def authorized(resp): +def authorized(): + resp = douban.authorized_response() if resp is None: return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], diff --git a/example/dropbox.py b/example/dropbox.py index 658710ed..d247215b 100644 --- a/example/dropbox.py +++ b/example/dropbox.py @@ -40,11 +40,11 @@ def logout(): @app.route('/login/authorized') -@dropbox.authorized_handler -def authorized(resp): +def authorized(): + resp = dropbox.authorized_response() if resp is None: return 'Access denied: reason=%s error=%s' % ( - request.args['error_reason'], + request.args['error'], request.args['error_description'] ) session['dropbox_token'] = (resp['access_token'], '') diff --git a/example/facebook.py b/example/facebook.py index 15928c3e..e7d8ab26 100644 --- a/example/facebook.py +++ b/example/facebook.py @@ -1,5 +1,5 @@ from flask import Flask, redirect, url_for, session, request -from flask_oauthlib.client import OAuth +from flask_oauthlib.client import OAuth, OAuthException FACEBOOK_APP_ID = '188477911223606' @@ -19,6 +19,7 @@ base_url='https://graph.facebook.com/', request_token_url=None, access_token_url='/oauth/access_token', + access_token_method='GET', authorize_url='https://www.facebook.com/dialog/oauth' ) @@ -30,19 +31,25 @@ def index(): @app.route('/login') def login(): - return facebook.authorize(callback=url_for('facebook_authorized', + callback = url_for( + 'facebook_authorized', next=request.args.get('next') or request.referrer or None, - _external=True)) + _external=True + ) + return facebook.authorize(callback=callback) @app.route('/login/authorized') -@facebook.authorized_handler -def facebook_authorized(resp): +def facebook_authorized(): + resp = facebook.authorized_response() if resp is None: return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], request.args['error_description'] ) + if isinstance(resp, OAuthException): + return 'Access denied: %s' % resp.message + session['oauth_token'] = (resp['access_token'], '') me = facebook.get('/me') return 'Logged in as id=%s name=%s redirect=%s' % \ diff --git a/example/github.py b/example/github.py index f80b896e..039c3aa9 100644 --- a/example/github.py +++ b/example/github.py @@ -40,12 +40,13 @@ def logout(): @app.route('/login/authorized') -@github.authorized_handler -def authorized(resp): - if resp is None: - return 'Access denied: reason=%s error=%s' % ( - request.args['error_reason'], - request.args['error_description'] +def authorized(): + resp = github.authorized_response() + if resp is None or resp.get('access_token') is None: + return 'Access denied: reason=%s error=%s resp=%s' % ( + request.args['error'], + request.args['error_description'], + resp ) session['github_token'] = (resp['access_token'], '') me = github.get('user') diff --git a/example/google.py b/example/google.py new file mode 100644 index 00000000..1aabf055 --- /dev/null +++ b/example/google.py @@ -0,0 +1,73 @@ +""" + google example + ~~~~~~~~~~~~~~ + + This example is contributed by Bruno Rocha + + GitHub: https://github.com/rochacbruno +""" +from flask import Flask, redirect, url_for, session, request, jsonify +from flask_oauthlib.client import OAuth + + +app = Flask(__name__) +app.config['GOOGLE_ID'] = "cloud.google.com/console and get your ID" +app.config['GOOGLE_SECRET'] = "cloud.google.com/console and get the secret" +app.debug = True +app.secret_key = 'development' +oauth = OAuth(app) + +google = oauth.remote_app( + 'google', + consumer_key=app.config.get('GOOGLE_ID'), + consumer_secret=app.config.get('GOOGLE_SECRET'), + request_token_params={ + 'scope': 'email' + }, + base_url='https://www.googleapis.com/oauth2/v1/', + request_token_url=None, + access_token_method='POST', + access_token_url='https://accounts.google.com/o/oauth2/token', + authorize_url='https://accounts.google.com/o/oauth2/auth', +) + + +@app.route('/') +def index(): + if 'google_token' in session: + me = google.get('userinfo') + return jsonify({"data": me.data}) + return redirect(url_for('login')) + + +@app.route('/login') +def login(): + return google.authorize(callback=url_for('authorized', _external=True)) + + +@app.route('/logout') +def logout(): + session.pop('google_token', None) + return redirect(url_for('index')) + + +@app.route('/login/authorized') +def authorized(): + resp = google.authorized_response() + if resp is None: + return 'Access denied: reason=%s error=%s' % ( + request.args['error_reason'], + request.args['error_description'] + ) + session['google_token'] = (resp['access_token'], '') + me = google.get('userinfo') + return jsonify({"data": me.data}) + + +@google.tokengetter +def get_google_oauth_token(): + return session.get('google_token') + + +if __name__ == '__main__': + app.run() diff --git a/example/linkedin.py b/example/linkedin.py index a9ad6c87..de898396 100644 --- a/example/linkedin.py +++ b/example/linkedin.py @@ -43,8 +43,8 @@ def logout(): @app.route('/login/authorized') -@linkedin.authorized_handler -def authorized(resp): +def authorized(): + resp = linkedin.authorized_response() if resp is None: return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], diff --git a/example/qq.py b/example/qq.py new file mode 100644 index 00000000..1bd9011e --- /dev/null +++ b/example/qq.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +import os +import json +from flask import Flask, redirect, url_for, session, request, jsonify, Markup +from flask_oauthlib.client import OAuth + +# get yours at http://connect.qq.com +QQ_APP_ID = os.getenv('QQ_APP_ID', '101187283') +QQ_APP_KEY = os.getenv('QQ_APP_KEY', '993983549da49e384d03adfead8b2489') + +app = Flask(__name__) +app.debug = True +app.secret_key = 'development' +oauth = OAuth(app) + +qq = oauth.remote_app( + 'qq', + consumer_key=QQ_APP_ID, + consumer_secret=QQ_APP_KEY, + base_url='https://graph.qq.com/', + request_token_url=None, + request_token_params={'scope': 'get_user_info'}, + access_token_url='/oauth2.0/token', + authorize_url='/oauth2.0/authorize', +) + + +def json_to_dict(x): + '''OAuthResponse class can't parse the JSON data with content-type +- text/html and because of a rubbish api, we can't just tell flask-oauthlib to treat it as json.''' + if x.find(b'callback') > -1: + # the rubbish api (https://graph.qq.com/oauth2.0/authorize) is handled here as special case + pos_lb = x.find(b'{') + pos_rb = x.find(b'}') + x = x[pos_lb:pos_rb + 1] + + try: + if type(x) != str: # Py3k + x = x.decode('utf-8') + return json.loads(x, encoding='utf-8') + except: + return x + + +def update_qq_api_request_data(data={}): + '''Update some required parameters for OAuth2.0 API calls''' + defaults = { + 'openid': session.get('qq_openid'), + 'access_token': session.get('qq_token')[0], + 'oauth_consumer_key': QQ_APP_ID, + } + defaults.update(data) + return defaults + + +@app.route('/') +def index(): + '''just for verify website owner here.''' + return Markup('''''') + + +@app.route('/user_info') +def get_user_info(): + if 'qq_token' in session: + data = update_qq_api_request_data() + resp = qq.get('/user/get_user_info', data=data) + return jsonify(status=resp.status, data=json_to_dict(resp.data)) + return redirect(url_for('login')) + + +@app.route('/login') +def login(): + return qq.authorize(callback=url_for('authorized', _external=True)) + + +@app.route('/logout') +def logout(): + session.pop('qq_token', None) + return redirect(url_for('get_user_info')) + + +@app.route('/login/authorized') +def authorized(): + resp = qq.authorized_response() + if resp is None: + return 'Access denied: reason=%s error=%s' % ( + request.args['error_reason'], + request.args['error_description'] + ) + session['qq_token'] = (resp['access_token'], '') + + # Get openid via access_token, openid and access_token are needed for API calls + resp = qq.get('/oauth2.0/me', {'access_token': session['qq_token'][0]}) + resp = json_to_dict(resp.data) + if isinstance(resp, dict): + session['qq_openid'] = resp.get('openid') + + return redirect(url_for('get_user_info')) + + +@qq.tokengetter +def get_qq_oauth_token(): + return session.get('qq_token') + + +def convert_keys_to_string(dictionary): + '''Recursively converts dictionary keys to strings.''' + if not isinstance(dictionary, dict): + return dictionary + return dict((str(k), convert_keys_to_string(v)) for k, v in dictionary.items()) + + +def change_qq_header(uri, headers, body): + '''On SAE platform, when headers' keys are unicode type, will raise + ``HTTP Error 400: Bad request``, so need convert keys from unicode to str. + Otherwise, ignored it.''' + # uncomment below line while deploy on SAE platform + # headers = convert_keys_to_string(headers) + return uri, headers, body + +qq.pre_request = change_qq_header + + +if __name__ == '__main__': + app.run() diff --git a/example/reddit.py b/example/reddit.py new file mode 100644 index 00000000..1352480c --- /dev/null +++ b/example/reddit.py @@ -0,0 +1,113 @@ +from flask import Flask, redirect, url_for, session, request +from flask_oauthlib.client import OAuth, OAuthException, OAuthRemoteApp, parse_response +from flask_oauthlib.utils import to_bytes +import uuid +import base64 +import time + +REDDIT_APP_ID = '6WnQXb-elQ3DLw' +REDDIT_APP_SECRET = 'KzQickJEBxNHmt5bpO_HmSiupTw' +# Reddit requires you to set nice User-Agent containing your username +REDDIT_USER_AGENT = 'flask-oauthlib testing by /u/' + +app = Flask(__name__) +app.debug = True +app.secret_key = 'development' +oauth = OAuth(app) + + +class RedditOAuthRemoteApp(OAuthRemoteApp): + def __init__(self, *args, **kwargs): + super(RedditOAuthRemoteApp, self).__init__(*args, **kwargs) + + def handle_oauth2_response(self): + if self.access_token_method != 'POST': + raise OAuthException( + 'Unsupported access_token_method: %s' % + self.access_token_method + ) + + client = self.make_client() + remote_args = { + 'code': request.args.get('code'), + 'client_secret': self.consumer_secret, + 'redirect_uri': session.get('%s_oauthredir' % self.name) + } + remote_args.update(self.access_token_params) + + reddit_basic_auth = base64.encodestring('%s:%s' % (REDDIT_APP_ID, REDDIT_APP_SECRET)).replace('\n', '') + body = client.prepare_request_body(**remote_args) + while True: + resp, content = self.http_request( + self.expand_url(http://github.com/self.access_token_url), + headers={'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic %s' % reddit_basic_auth, + 'User-Agent': REDDIT_USER_AGENT}, + data=to_bytes(body, self.encoding), + method=self.access_token_method, + ) + # Reddit API is rate-limited, so if we get 429, we need to retry + if resp.code != 429: + break + time.sleep(1) + + data = parse_response(resp, content, content_type=self.content_type) + if resp.code not in (200, 201): + raise OAuthException( + 'Invalid response from %s' % self.name, + type='invalid_response', data=data + ) + return data + +reddit = RedditOAuthRemoteApp( + oauth, + 'reddit', + consumer_key=REDDIT_APP_ID, + consumer_secret=REDDIT_APP_SECRET, + request_token_params={'scope': 'identity'}, + base_url='https://oauth.reddit.com/api/v1/', + request_token_url=None, + access_token_url='https://www.reddit.com/api/v1/access_token', + access_token_method='POST', + authorize_url='https://www.reddit.com/api/v1/authorize' +) + +oauth.remote_apps['reddit'] = reddit + + +@app.route('/') +def index(): + return redirect(url_for('login')) + + +@app.route('/login') +def login(): + callback = url_for('reddit_authorized', _external=True) + return reddit.authorize(callback=callback, state=uuid.uuid4()) + + +@app.route('/login/authorized') +def reddit_authorized(): + resp = reddit.authorized_response() + if isinstance(resp, OAuthException): + print(resp.data) + + if resp is None: + return 'Access denied: error=%s' % request.args['error'], + session['reddit_oauth_token'] = (resp['access_token'], '') + + # This request may fail(429 Too Many Requests) + # If you plan to use API heavily(and not just for auth), + # it may be better to use PRAW: https://github.com/praw-dev/praw + me = reddit.get('me') + return 'Logged in as name=%s link_karma=%s comment_karma=%s' % \ + (me.data['name'], me.data['link_karma'], me.data['comment_karma']) + + +@reddit.tokengetter +def get_reddit_oauth_token(): + return session.get('reddit_oauth_token') + + +if __name__ == '__main__': + app.run() diff --git a/example/templates/index.html b/example/templates/index.html index 361d4655..48612ccc 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -4,17 +4,19 @@

Overview

{% if g.user %}

Hello {{ g.user.screen_name }}! Wanna tweet something? +

+

{% if tweets %}

Your Timeline

    {% for tweet in tweets %}
  • {{ tweet.user.screen_name }}: {{ tweet.text|urlize }} + }}">{{ tweet.user.screen_name }}: {{ tweet.text|urlize }}
  • {% endfor %}
{% endif %} @@ -22,8 +24,10 @@

Your Timeline

Sign in to view your public timeline and to tweet from this example application. +

sign in +

{% endif %} {% endblock %} diff --git a/example/templates/layout.html b/example/templates/layout.html index 6c9649d0..b27b3530 100644 --- a/example/templates/layout.html +++ b/example/templates/layout.html @@ -1,17 +1,22 @@ + {% block title %}Welcome{% endblock %} | Flask OAuth Example + +

Flask OAuth Example

{% for message in get_flashed_messages() %} -

{{ message }} +

{{ message }}

{% endfor %} {% block body %}{% endblock %} + + diff --git a/example/twitter.py b/example/twitter.py index 061e1343..02a8d266 100644 --- a/example/twitter.py +++ b/example/twitter.py @@ -19,7 +19,7 @@ base_url='https://api.twitter.com/1.1/', request_token_url='https://api.twitter.com/oauth/request_token', access_token_url='https://api.twitter.com/oauth/access_token', - authorize_url='https://api.twitter.com/oauth/authenticate', + authorize_url='https://api.twitter.com/oauth/authorize' ) @@ -59,8 +59,12 @@ def tweet(): resp = twitter.post('statuses/update.json', data={ 'status': status }) + if resp.status == 403: - flash('Your tweet was too long.') + flash("Error: #%d, %s " % ( + resp.data.get('errors')[0].get('code'), + resp.data.get('errors')[0].get('message')) + ) elif resp.status == 401: flash('Authorization error with Twitter.') else: @@ -81,8 +85,8 @@ def logout(): @app.route('/oauthorized') -@twitter.authorized_handler -def oauthorized(resp): +def oauthorized(): + resp = twitter.authorized_response() if resp is None: flash('You denied the request to sign in.') else: diff --git a/example/weibo.py b/example/weibo.py index 7f548ce8..a4c8404c 100644 --- a/example/weibo.py +++ b/example/weibo.py @@ -45,8 +45,8 @@ def logout(): @app.route('/login/authorized') -@weibo.authorized_handler -def authorized(resp): +def authorized(): + resp = weibo.authorized_response() if resp is None: return 'Access denied: reason=%s error=%s' % ( request.args['error_reason'], diff --git a/flask_oauthlib/__init__.py b/flask_oauthlib/__init__.py index bb71ec49..a8d492e3 100644 --- a/flask_oauthlib/__init__.py +++ b/flask_oauthlib/__init__.py @@ -11,7 +11,7 @@ :license: BSD, see LICENSE for more details. """ -__version__ = "0.4.1" +__version__ = "0.9.6" __author__ = "Hsiaoming Yang " __homepage__ = 'https://github.com/lepture/flask-oauthlib' __license__ = 'BSD' diff --git a/flask_oauthlib/client.py b/flask_oauthlib/client.py index bba83bdf..b4497f46 100644 --- a/flask_oauthlib/client.py +++ b/flask_oauthlib/client.py @@ -5,17 +5,20 @@ Implemnts OAuth1 and OAuth2 support for Flask. - :copyright: (c) 2013 by Hsiaoming Yang. + :copyright: (c) 2013 - 2014 by Hsiaoming Yang. """ import logging import oauthlib.oauth1 import oauthlib.oauth2 +from copy import copy from functools import wraps from oauthlib.common import to_unicode, PY3, add_params_to_uri from flask import request, redirect, json, session, current_app -from werkzeug import url_quote, url_decode, url_encode -from werkzeug import parse_options_header, cached_property +from werkzeug.urls import url_quote, url_decode, url_encode +from werkzeug.http import parse_options_header +from werkzeug.utils import cached_property +from .utils import to_bytes try: from urlparse import urljoin import urllib2 as http @@ -25,6 +28,12 @@ log = logging.getLogger('flask_oauthlib') +if PY3: + string_types = (str,) +else: + string_types = (str, unicode) + + __all__ = ('OAuth', 'OAuthRemoteApp', 'OAuthResponse', 'OAuthException') @@ -37,6 +46,7 @@ class OAuth(object): oauth = OAuth(app) """ + state_key = 'oauthlib.client' def __init__(self, app=None): self.remote_apps = {} @@ -55,7 +65,7 @@ def init_app(self, app): """ self.app = app app.extensions = getattr(app, 'extensions', {}) - app.extensions['oauthlib.client'] = self + app.extensions[self.state_key] = self def remote_app(self, name, register=True, **kwargs): """Registers a new remote application. @@ -114,6 +124,8 @@ def parse_response(resp, content, strict=False, content_type=None): ct, options = parse_options_header(content_type) if ct in ('application/json', 'text/javascript'): + if not content: + return {} return json.loads(content) if ct in ('application/xml', 'text/xml'): @@ -197,10 +209,14 @@ class OAuthRemoteApp(object): to forward to the request token url or authorize url depending on oauth version + :param request_token_method: the HTTP method that should be used for + the access_token_url. Default is ``GET`` :param access_token_params: an optional dictionary of parameters to forward to the access token url :param access_token_method: the HTTP method that should be used for the access_token_url. Default is ``GET`` + :param access_token_headers: additonal headers that should be used for + the access_token_url. :param content_type: force to parse the content with this content_type, usually used when the server didn't return the right content type. @@ -218,9 +234,13 @@ def __init__( authorize_url=None, consumer_key=None, consumer_secret=None, + rsa_key=None, + signature_method=None, request_token_params=None, + request_token_method=None, access_token_params=None, access_token_method=None, + access_token_headers=None, content_type=None, app_key=None, encoding='utf-8', @@ -228,26 +248,43 @@ def __init__( self.oauth = oauth self.name = name - if (not consumer_key or not consumer_secret) and not app_key: - raise TypeError( - 'OAuthRemoteApp requires consumer key and secret' - ) - self._base_url = base_url self._request_token_url = request_token_url self._access_token_url = access_token_url self._authorize_url = authorize_url self._consumer_key = consumer_key self._consumer_secret = consumer_secret + self._rsa_key = rsa_key + self._signature_method = signature_method self._request_token_params = request_token_params + self._request_token_method = request_token_method self._access_token_params = access_token_params self._access_token_method = access_token_method + self._access_token_headers = access_token_headers or {} self._content_type = content_type self._tokengetter = None self.app_key = app_key self.encoding = encoding + # Check for required authentication information. + # Skip this check if app_key is specified, since the information is + # specified in the Flask config, instead. + if not app_key: + if signature_method == oauthlib.oauth1.SIGNATURE_RSA: + # check for consumer_key and rsa_key + if not consumer_key or not rsa_key: + raise TypeError( + "OAuthRemoteApp with RSA authentication requires " + "consumer key and rsa key" + ) + else: + # check for consumer_key and consumer_secret + if not consumer_key or not consumer_secret: + raise TypeError( + "OAuthRemoteApp requires consumer key and secret" + ) + @cached_property def base_url(http://github.com/self): return self._get_property('base_url') @@ -272,17 +309,29 @@ def consumer_key(self): def consumer_secret(self): return self._get_property('consumer_secret') + @cached_property + def rsa_key(self): + return self._get_property('rsa_key') + + @cached_property + def signature_method(self): + return self._get_property('signature_method') + @cached_property def request_token_params(self): return self._get_property('request_token_params', {}) + @cached_property + def request_token_method(self): + return self._get_property('request_token_method', 'GET') + @cached_property def access_token_params(self): return self._get_property('access_token_params', {}) @cached_property def access_token_method(self): - return self._get_property('access_token_method', 'GET') + return self._get_property('access_token_method', 'POST') @cached_property def content_type(self): @@ -309,26 +358,35 @@ def _get_property(self, key, default=False): return app.config.get(config_key, default) return app.config[config_key] + def get_oauth1_client_params(self, token): + params = copy(self.request_token_params) or {} + if token and isinstance(token, (tuple, list)): + params["resource_owner_key"] = token[0] + params["resource_owner_secret"] = token[1] + + # Set params for SIGNATURE_RSA + if self.signature_method == oauthlib.oauth1.SIGNATURE_RSA: + params["signature_method"] = self.signature_method + params["rsa_key"] = self.rsa_key + + return params + def make_client(self, token=None): # request_token_url is for oauth1 if self.request_token_url: + # get params for client + params = self.get_oauth1_client_params(token) client = oauthlib.oauth1.Client( - self.consumer_key, self.consumer_secret + client_key=self.consumer_key, + client_secret=self.consumer_secret, + **params ) - - params = self.request_token_params or {} - if 'signature_method' in params: - client.signature_method = _encode(params['signature_method']) - if 'rsa_key' in params: - client.rsa_key = _encode(params['rsa_key']) - if 'signature_type' in params: - client.signature_type = _encode(params['signature_type']) - - if token and isinstance(token, (tuple, list)): - client.resource_owner_key, client.resource_owner_secret = token else: - if token and isinstance(token, (tuple, list)): - token = {'access_token': token[0]} + if token: + if isinstance(token, (tuple, list)): + token = {'access_token': token[0]} + elif isinstance(token, string_types): + token = {'access_token': token} client = oauthlib.oauth2.WebApplicationClient( self.consumer_key, token=token ) @@ -354,33 +412,40 @@ def http_request(uri, headers=None, data=None, method=None): return resp, content def get(self, *args, **kwargs): - """Sends a ``GET`` request. Accepts the same paramters as + """Sends a ``GET`` request. Accepts the same parameters as :meth:`request`. """ kwargs['method'] = 'GET' return self.request(*args, **kwargs) def post(self, *args, **kwargs): - """Sends a ``POST`` request. Accepts the same paramters as + """Sends a ``POST`` request. Accepts the same parameters as :meth:`request`. """ kwargs['method'] = 'POST' return self.request(*args, **kwargs) def put(self, *args, **kwargs): - """Sends a ``PUT`` request. Accepts the same paramters as + """Sends a ``PUT`` request. Accepts the same parameters as :meth:`request`. """ kwargs['method'] = 'PUT' return self.request(*args, **kwargs) def delete(self, *args, **kwargs): - """Sends a ``DELETE`` request. Accepts the same paramters as + """Sends a ``DELETE`` request. Accepts the same parameters as :meth:`request`. """ kwargs['method'] = 'DELETE' return self.request(*args, **kwargs) + def patch(self, *args, **kwargs): + """Sends a ``PATCH`` request. Accepts the same parameters as + :meth:`post`. + """ + kwargs['method'] = 'PATCH' + return self.request(*args, **kwargs) + def request(self, url, data=None, headers=None, format='urlencoded', method='GET', content_type=None, token=None): """ @@ -395,11 +460,11 @@ def request(self, url, data=None, headers=None, format='urlencoded', is provided, the data is passed as it, and the `format` is ignored. :param token: an optional token to pass, if it is None, token will - be generated be tokengetter. + be generated by tokengetter. """ headers = dict(headers or {}) - if not token: + if token is None: token = self.get_request_token() client = self.make_client(token) @@ -427,30 +492,44 @@ def request(self, url, data=None, headers=None, format='urlencoded', ) if hasattr(self, 'pre_request'): - # this is desgined for some rubbish service like weibo - # since they don't follow the standards, we need to - # change the uri, headers, or body + # This is designed for some rubbish services like weibo. + # Since they don't follow the standards, we need to + # change the uri, headers, or body. uri, headers, body = self.pre_request(uri, headers, body) + if body: + data = to_bytes(body, self.encoding) + else: + data = None resp, content = self.http_request( - uri, headers, data=body, method=method + uri, headers, data=to_bytes(body, self.encoding), method=method ) return OAuthResponse(resp, content, self.content_type) - def authorize(self, callback=None): + def authorize(self, callback=None, state=None, **kwargs): """ Returns a redirect response to the remote authorization URL with the signed callback given. + + :param callback: a redirect url for the callback + :param state: an optional value to embed in the OAuth request. + Use this if you want to pass around application + state (e.g. CSRF tokens). + :param kwargs: add optional key/value pairs to the query string """ + params = dict(self.request_token_params) or {} + params.update(**kwargs) + if self.request_token_url: token = self.generate_request_token(callback)[0] url = '%s?oauth_token=%s' % ( self.expand_url(http://github.com/self.authorize_url), url_quote(token) ) + if params: + url += '&' + url_encode(params) else: - assert callback is not None, 'Callback is required OAuth2' + assert callback is not None, 'Callback is required for OAuth2' - params = dict(self.request_token_params) or {} client = self.make_client() if 'scope' in params: @@ -462,11 +541,23 @@ def authorize(self, callback=None): # oauthlib need unicode scope = _encode(scope, self.encoding) + if 'state' in params: + if not state: + state = params.pop('state') + else: + # remove state in params + params.pop('state') + + if callable(state): + # state can be function for generate a random string + state = state() + session['%s_oauthredir' % self.name] = callback url = client.prepare_request_uri( self.expand_url(http://github.com/self.authorize_url), redirect_uri=callback, scope=scope, + state=state, **params ) return redirect(url) @@ -494,21 +585,29 @@ def generate_request_token(self, callback=None): if not realm and realms: realm = ' '.join(realms) uri, headers, _ = client.sign( - self.expand_url(http://github.com/self.request_token_url), realm=realm + self.expand_url(http://github.com/self.request_token_url), + http_method=self.request_token_method, + realm=realm, ) log.debug('Generate request token header %r', headers) - resp, content = self.http_request(uri, headers) - if resp.code not in (200, 201): - raise OAuthException( - 'Failed to generate request token', - type='token_generation_failed' - ) + resp, content = self.http_request( + uri, headers, method=self.request_token_method, + ) data = parse_response(resp, content) - if data is None: + if not data: raise OAuthException( 'Invalid token response from %s' % self.name, type='token_generation_failed' ) + if resp.code not in (200, 201): + message = 'Failed to generate request token' + if 'oauth_problem' in data: + message += ' (%s)' % data['oauth_problem'] + raise OAuthException( + message, + type='token_generation_failed', + data=data, + ) tup = (data['oauth_token'], data['oauth_token_secret']) session['%s_oauthtok' % self.name] = tup return tup @@ -520,11 +619,16 @@ def get_request_token(self): raise OAuthException('No token available', type='token_missing') return rv - def handle_oauth1_response(self): + def handle_oauth1_response(self, args): """Handles an oauth1 authorization response.""" client = self.make_client() - client.verifier = request.args.get('oauth_verifier') + client.verifier = args.get('oauth_verifier') tup = session.get('%s_oauthtok' % self.name) + if not tup: + raise OAuthException( + 'Token not found, maybe you disabled cookie', + type='token_not_found' + ) client.resource_owner_key = tup[0] client.resource_owner_secret = tup[1] @@ -532,8 +636,12 @@ def handle_oauth1_response(self): self.expand_url(http://github.com/self.access_token_url), _encode(self.access_token_method) ) + headers.update(self._access_token_headers) - resp, content = self.http_request(uri, headers, data) + resp, content = self.http_request( + uri, headers, to_bytes(data, self.encoding), + method=self.access_token_method + ) data = parse_response(resp, content) if resp.code not in (200, 201): raise OAuthException( @@ -542,22 +650,25 @@ def handle_oauth1_response(self): ) return data - def handle_oauth2_response(self): + def handle_oauth2_response(self, args): """Handles an oauth2 authorization response.""" client = self.make_client() remote_args = { - 'code': request.args.get('code'), + 'code': args.get('code'), 'client_secret': self.consumer_secret, 'redirect_uri': session.get('%s_oauthredir' % self.name) } log.debug('Prepare oauth2 remote args %r', remote_args) remote_args.update(self.access_token_params) + headers = copy(self._access_token_headers) if self.access_token_method == 'POST': + headers.update({'Content-Type': 'application/x-www-form-urlencoded'}) body = client.prepare_request_body(**remote_args) resp, content = self.http_request( self.expand_url(http://github.com/self.access_token_url), - data=body, + headers=headers, + data=to_bytes(body, self.encoding), method=self.access_token_method, ) elif self.access_token_method == 'GET': @@ -566,6 +677,7 @@ def handle_oauth2_response(self): url += ('?' in url and '&' or '?') + qs resp, content = self.http_request( url, + headers=headers, method=self.access_token_method, ) else: @@ -586,25 +698,35 @@ def handle_unknown_response(self): """Handles a unknown authorization response.""" return None + def authorized_response(self, args=None): + """Handles authorization response smartly.""" + if args is None: + args = request.args + if 'oauth_verifier' in args: + data = self.handle_oauth1_response(args) + elif 'code' in args: + data = self.handle_oauth2_response(args) + else: + data = self.handle_unknown_response() + + # free request token + session.pop('%s_oauthtok' % self.name, None) + session.pop('%s_oauthredir' % self.name, None) + return data + def authorized_handler(self, f): + """Handles an OAuth callback. + + .. versionchanged:: 0.7 + @authorized_handler is deprecated in favor of authorized_response. + """ @wraps(f) def decorated(*args, **kwargs): - if 'oauth_verifier' in request.args: - try: - data = self.handle_oauth1_response() - except OAuthException as e: - data = e - elif 'code' in request.args: - try: - data = self.handle_oauth2_response() - except OAuthException as e: - data = e - else: - data = self.handle_unknown_response() - - # free request token - session.pop('%s_oauthtok' % self.name, None) - session.pop('%s_oauthredir' % self.name, None) + log.warn( + '@authorized_handler is deprecated in favor of ' + 'authorized_response' + ) + data = self.authorized_response() return f(*((data,) + args), **kwargs) return decorated diff --git a/flask_oauthlib/contrib/__init__.py b/flask_oauthlib/contrib/__init__.py index bcfa8607..f1c7842f 100644 --- a/flask_oauthlib/contrib/__init__.py +++ b/flask_oauthlib/contrib/__init__.py @@ -5,5 +5,5 @@ Contributions for Flask OAuthlib. - :copyright: (c) 2013 by Hsiaoming Yang. + :copyright: (c) 2013 - 2014 by Hsiaoming Yang. """ diff --git a/flask_oauthlib/contrib/apps.py b/flask_oauthlib/contrib/apps.py new file mode 100644 index 00000000..09b67e88 --- /dev/null +++ b/flask_oauthlib/contrib/apps.py @@ -0,0 +1,229 @@ +""" + flask_oauthlib.contrib.apps + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + The bundle of remote app factories for famous third platforms. + + Usage:: + + from flask import Flask + from flask_oauthlib.client import OAuth + from flask_oauthlib.contrib.apps import github + + app = Flask(__name__) + oauth = OAuth(app) + + github.register_to(oauth, scope=['user:email']) + github.register_to(oauth, name='github2') + + Of course, it requires consumer keys in your config:: + + GITHUB_CONSUMER_KEY = '' + GITHUB_CONSUMER_SECRET = '' + GITHUB2_CONSUMER_KEY = '' + GITHUB2_CONSUMER_SECRET = '' + + Some apps with OAuth 1.0a such as Twitter could not accept the ``scope`` + argument. + + Contributed by: tonyseek +""" + +import copy + +from oauthlib.common import unicode_type, bytes_type + + +__all__ = ['douban', 'dropbox', 'facebook', 'github', 'google', 'linkedin', + 'twitter', 'weibo'] + + +class RemoteAppFactory(object): + """The factory to create remote app and bind it to given extension. + + :param default_name: the default name which be used for registering. + :param kwargs: the pre-defined kwargs. + :param docstring: the docstring of factory. + """ + + def __init__(self, default_name, kwargs, docstring=''): + assert 'name' not in kwargs + assert 'register' not in kwargs + self.default_name = default_name + self.kwargs = kwargs + self._kwargs_processor = None + self.__doc__ = docstring.lstrip() + + def register_to(self, oauth, name=None, **kwargs): + """Creates a remote app and registers it.""" + kwargs = self._process_kwargs( + name=(name or self.default_name), **kwargs) + return oauth.remote_app(**kwargs) + + def create(self, oauth, **kwargs): + """Creates a remote app only.""" + kwargs = self._process_kwargs( + name=self.default_name, register=False, **kwargs) + return oauth.remote_app(**kwargs) + + def kwargs_processor(self, fn): + """Sets a function to process kwargs before creating any app.""" + self._kwargs_processor = fn + return fn + + def _process_kwargs(self, **kwargs): + final_kwargs = copy.deepcopy(self.kwargs) + # merges with pre-defined kwargs + final_kwargs.update(copy.deepcopy(kwargs)) + # use name as app key + final_kwargs.setdefault('app_key', final_kwargs['name'].upper()) + # processes by pre-defined function + if self._kwargs_processor is not None: + final_kwargs = self._kwargs_processor(**final_kwargs) + return final_kwargs + + +def make_scope_processor(default_scope): + def processor(**kwargs): + # request_token_params + scope = kwargs.pop('scope', [default_scope]) # default scope + if not isinstance(scope, (unicode_type, bytes_type)): + scope = ','.join(scope) # allows list-style scope + request_token_params = kwargs.setdefault('request_token_params', {}) + request_token_params.setdefault('scope', scope) # doesn't override + return kwargs + return processor + + +douban = RemoteAppFactory('douban', { + 'base_url': 'https://api.douban.com/v2/', + 'request_token_url': None, + 'access_token_url': 'https://www.douban.com/service/auth2/token', + 'authorize_url': 'https://www.douban.com/service/auth2/auth', + 'access_token_method': 'POST', +}, """ +The OAuth app for douban.com API. + +:param scope: optional. default: ``['douban_basic_common']``. + see also: http://developers.douban.com/wiki/?title=oauth2 +""") +douban.kwargs_processor(make_scope_processor('douban_basic_common')) + + +dropbox = RemoteAppFactory('dropbox', { + 'base_url': 'https://www.dropbox.com/1/', + 'request_token_url': None, + 'access_token_url': 'https://api.dropbox.com/1/oauth2/token', + 'authorize_url': 'https://www.dropbox.com/1/oauth2/authorize', + 'access_token_method': 'POST', + 'request_token_params': {}, +}, """The OAuth app for Dropbox API.""") + + +facebook = RemoteAppFactory('facebook', { + 'request_token_params': {'scope': 'email'}, + 'base_url': 'https://graph.facebook.com/', + 'request_token_url': None, + 'access_token_url': '/oauth/access_token', + 'authorize_url': 'https://www.facebook.com/dialog/oauth', +}, """ +The OAuth app for Facebook API. + +:param scope: optional. default: ``['email']``. +""") +facebook.kwargs_processor(make_scope_processor('email')) + + +github = RemoteAppFactory('github', { + 'base_url': 'https://api.github.com/', + 'request_token_url': None, + 'access_token_method': 'POST', + 'access_token_url': 'https://github.com/login/oauth/access_token', + 'authorize_url': 'https://github.com/login/oauth/authorize', +}, """ +The OAuth app for GitHub API. + +:param scope: optional. default: ``['user:email']``. +""") +github.kwargs_processor(make_scope_processor('user:email')) + + +google = RemoteAppFactory('google', { + 'base_url': 'https://www.googleapis.com/oauth2/v1/', + 'request_token_url': None, + 'access_token_method': 'POST', + 'access_token_url': 'https://accounts.google.com/o/oauth2/token', + 'authorize_url': 'https://accounts.google.com/o/oauth2/auth', +}, """ +The OAuth app for Google API. + +:param scope: optional. + default: ``['email']``. +""") +google.kwargs_processor(make_scope_processor( + 'email')) + + +twitter = RemoteAppFactory('twitter', { + 'base_url': 'https://api.twitter.com/1.1/', + 'request_token_url': 'https://api.twitter.com/oauth/request_token', + 'access_token_url': 'https://api.twitter.com/oauth/access_token', + 'authorize_url': 'https://api.twitter.com/oauth/authenticate', +}, """The OAuth app for Twitter API.""") + + +weibo = RemoteAppFactory('weibo', { + 'base_url': 'https://api.weibo.com/2/', + 'authorize_url': 'https://api.weibo.com/oauth2/authorize', + 'request_token_url': None, + 'access_token_method': 'POST', + 'access_token_url': 'https://api.weibo.com/oauth2/access_token', + # since weibo's response is a shit, we need to force parse the content + 'content_type': 'application/json', +}, """ +The OAuth app for weibo.com API. + +:param scope: optional. default: ``['email']`` +""") +weibo.kwargs_processor(make_scope_processor('email')) + + +def change_weibo_header(uri, headers, body): + """Since weibo is a rubbish server, it does not follow the standard, + we need to change the authorization header for it.""" + auth = headers.get('Authorization') + if auth: + auth = auth.replace('Bearer', 'OAuth2') + headers['Authorization'] = auth + return uri, headers, body + +weibo.pre_request = change_weibo_header + + +linkedin = RemoteAppFactory('linkedin', { + 'request_token_params': {'state': 'RandomString'}, + 'base_url': 'https://api.linkedin.com/v1/', + 'request_token_url': None, + 'access_token_method': 'POST', + 'access_token_url': 'https://www.linkedin.com/uas/oauth2/accessToken', + 'authorize_url': 'https://www.linkedin.com/uas/oauth2/authorization', +}, """ +The OAuth app for LinkedIn API. + +:param scope: optional. default: ``['r_basicprofile']`` +""") +linkedin.kwargs_processor(make_scope_processor('r_basicprofile')) + + +def change_linkedin_query(uri, headers, body): + auth = headers.pop('Authorization') + headers['x-li-format'] = 'json' + if auth: + auth = auth.replace('Bearer', '').strip() + if '?' in uri: + uri += '&oauth2_access_token=' + auth + else: + uri += '?oauth2_access_token=' + auth + return uri, headers, body + +linkedin.pre_request = change_linkedin_query diff --git a/flask_oauthlib/contrib/cache.py b/flask_oauthlib/contrib/cache.py index 34259923..6adb62e1 100644 --- a/flask_oauthlib/contrib/cache.py +++ b/flask_oauthlib/contrib/cache.py @@ -1,7 +1,7 @@ # coding: utf-8 -from werkzeug.contrib.cache import NullCache, SimpleCache, FileSystemCache -from werkzeug.contrib.cache import MemcachedCache, RedisCache +from cachelib import NullCache, SimpleCache, FileSystemCache +from cachelib import MemcachedCache, RedisCache class Cache(object): @@ -20,6 +20,7 @@ def __init__(self, app, config_prefix='OAUTHLIB', **kwargs): raise RuntimeError( '`%s` is not a valid cache type!' % cache_type ) + app.extensions[config_prefix.lower() + '_cache'] = self.cache def __getattr__(self, key): try: diff --git a/flask_oauthlib/contrib/client/__init__.py b/flask_oauthlib/contrib/client/__init__.py new file mode 100644 index 00000000..4b777b44 --- /dev/null +++ b/flask_oauthlib/contrib/client/__init__.py @@ -0,0 +1,104 @@ +import copy + +from flask import current_app +from werkzeug.local import LocalProxy + +from .application import OAuth1Application, OAuth2Application + + +__all__ = ['OAuth', 'OAuth1Application', 'OAuth2Application'] + + +class OAuth(object): + """The extension to integrate OAuth 1.0a/2.0 to Flask applications. + + oauth = OAuth(app) + + or:: + + oauth = OAuth() + oauth.init_app(app) + """ + + state_key = 'oauthlib.contrib.client' + + def __init__(self, app=None): + self.remote_apps = {} + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.extensions = getattr(app, 'extensions', {}) + app.extensions[self.state_key] = OAuthState() + + def add_remote_app(self, remote_app, name=None, **kwargs): + """Adds remote application and applies custom attributes on it. + + If the application instance's name is different from the argument + provided name, or the keyword arguments is not empty, then the + application instance will not be modified but be copied as a + prototype. + + :param remote_app: the remote application instance. + :type remote_app: the subclasses of :class:`BaseApplication` + :params kwargs: the overriding attributes for the application instance. + """ + if name is None: + name = remote_app.name + if name != remote_app.name or kwargs: + remote_app = copy.copy(remote_app) + remote_app.name = name + vars(remote_app).update(kwargs) + if not hasattr(remote_app, 'clients'): + remote_app.clients = cached_clients + self.remote_apps[name] = remote_app + return remote_app + + def remote_app(self, name, version=None, **kwargs): + """Creates and adds new remote application. + + :param name: the remote application's name. + :param version: '1' or '2', the version code of OAuth protocol. + :param kwargs: the attributes of remote application. + """ + if version is None: + if 'request_token_url' in kwargs: + version = '1' + else: + version = '2' + if version == '1': + remote_app = OAuth1Application(name, clients=cached_clients) + elif version == '2': + remote_app = OAuth2Application(name, clients=cached_clients) + else: + raise ValueError('unkonwn version %r' % version) + return self.add_remote_app(remote_app, **kwargs) + + def __getitem__(self, name): + return self.remote_apps[name] + + def __getattr__(self, key): + try: + return object.__getattribute__(self, key) + except AttributeError: + app = self.remote_apps.get(key) + if app: + return app + raise AttributeError('No such app: %s' % key) + + +class OAuthState(object): + + def __init__(self): + self.cached_clients = {} + + +def get_cached_clients(): + """Gets the cached clients dictionary in current context.""" + if OAuth.state_key not in current_app.extensions: + raise RuntimeError('%r is not initialized.' % current_app) + state = current_app.extensions[OAuth.state_key] + return state.cached_clients + + +cached_clients = LocalProxy(get_cached_clients) diff --git a/flask_oauthlib/contrib/client/application.py b/flask_oauthlib/contrib/client/application.py new file mode 100644 index 00000000..0694ace1 --- /dev/null +++ b/flask_oauthlib/contrib/client/application.py @@ -0,0 +1,339 @@ +""" + flask_oauthlib.contrib.client + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + An experiment client with requests-oauthlib as backend. +""" + +import os +import contextlib +import warnings +try: + from urllib.parse import urljoin +except ImportError: + from urlparse import urljoin + +from flask import current_app, redirect, request +from requests_oauthlib import OAuth1Session, OAuth2Session +from requests_oauthlib.oauth1_session import TokenMissing +from oauthlib.oauth2.rfc6749.errors import MissingCodeError +from werkzeug.utils import import_string + +from .descriptor import OAuthProperty, WebSessionData +from .structure import OAuth1Response, OAuth2Response +from .exceptions import AccessTokenNotFound +from .signals import request_token_fetched + + +__all__ = ['OAuth1Application', 'OAuth2Application'] + + +class BaseApplication(object): + """The base class of OAuth application. + + An application instance could be used in mupltiple context. It never stores + any session-scope state in the ``__dict__`` of itself. + + :param name: the name of this application. + :param clients: optional. a reference to the cached clients dictionary. + """ + + session_class = None + endpoint_url = OAuthProperty('endpoint_url', default='') + + def __init__(self, name, clients=None, **kwargs): + # oauth property required + self.name = name + + if clients: + self.clients = clients + + # other descriptor assignable attributes + for k, v in kwargs.items(): + if not hasattr(self.__class__, k): + raise TypeError('descriptor %r not found' % k) + setattr(self, k, v) + + def __repr__(self): + class_name = self.__class__.__name__ + return '<%s:%s at %s>' % (class_name, self.name, hex(id(self))) + + def tokengetter(self, fn): + self._tokengetter = fn + return fn + + def obtain_token(self): + """Obtains the access token by calling ``tokengetter`` which was + defined by users. + + :returns: token or ``None``. + """ + tokengetter = getattr(self, '_tokengetter', None) + if tokengetter is None: + raise RuntimeError('%r missing tokengetter' % self) + return tokengetter() + + @property + def client(self): + """The lazy-created OAuth session with the return value of + :meth:`tokengetter`. + + :returns: The OAuth session instance or ``None`` while token missing. + """ + token = self.obtain_token() + if token is None: + raise AccessTokenNotFound + return self._make_client_with_token(token) + + def _make_client_with_token(self, token): + """Uses cached client or create new one with specific token.""" + cached_clients = getattr(self, 'clients', None) + hashed_token = _hash_token(self, token) + + if cached_clients and hashed_token in cached_clients: + return cached_clients[hashed_token] + + client = self.make_client(token) # implemented in subclasses + if cached_clients: + cached_clients[hashed_token] = client + + return client + + def authorize(self, callback_uri, code=302): + """Redirects to third-part URL and authorizes. + + :param callback_uri: the callback URI. if you generate it with the + :func:`~flask.url_for`, don't forget to use the + ``_external=True`` keyword argument. + :param code: default is 302. the HTTP code for redirection. + :returns: the redirection response. + """ + raise NotImplementedError + + def authorized_response(self): + """Obtains access token from third-part API. + + :returns: the response with the type of :class:`OAuthResponse` dict, + or ``None`` if the authorization has been denied. + """ + raise NotImplementedError + + def request(self, method, url, token=None, *args, **kwargs): + if token is None: + client = self.client + else: + client = self._make_client_with_token(token) + url = urljoin(self.endpoint_url, url) + return getattr(client, method)(url, *args, **kwargs) + + def head(self, *args, **kwargs): + return self.request('head', *args, **kwargs) + + def get(self, *args, **kwargs): + return self.request('get', *args, **kwargs) + + def post(self, *args, **kwargs): + return self.request('post', *args, **kwargs) + + def put(self, *args, **kwargs): + return self.request('put', *args, **kwargs) + + def delete(self, *args, **kwargs): + return self.request('delete', *args, **kwargs) + + def patch(self, *args, **kwargs): + return self.request('patch', *args, **kwargs) + + +class OAuth1Application(BaseApplication): + """The remote application for OAuth 1.0a.""" + + request_token_url = OAuthProperty('request_token_url') + access_token_url = OAuthProperty('access_token_url') + authorization_url = OAuthProperty('authorization_url') + + consumer_key = OAuthProperty('consumer_key') + consumer_secret = OAuthProperty('consumer_secret') + + session_class = OAuth1Session + + def make_client(self, token): + """Creates a client with specific access token pair. + + :param token: a tuple of access token pair ``(token, token_secret)`` + or a dictionary of access token response. + :returns: a :class:`requests_oauthlib.oauth1_session.OAuth1Session` + object. + """ + if isinstance(token, dict): + access_token = token['oauth_token'] + access_token_secret = token['oauth_token_secret'] + else: + access_token, access_token_secret = token + return self.make_oauth_session( + resource_owner_key=access_token, + resource_owner_secret=access_token_secret) + + def authorize(self, callback_uri, code=302): + # TODO add support for oauth_callback=oob (out-of-band) here + # http://tools.ietf.org/html/rfc5849#section-2.1 + oauth = self.make_oauth_session(callback_uri=callback_uri) + + # fetches request token + token = oauth.fetch_request_token(self.request_token_url) + request_token_fetched.send(self, response=OAuth1Response(token)) + # TODO check oauth_callback_confirmed here + # http://tools.ietf.org/html/rfc5849#section-2.1 + + # redirects to third-part URL + authorization_url = oauth.authorization_url(http://github.com/self.authorization_url) + return redirect(authorization_url, code) + + def authorized_response(self): + oauth = self.make_oauth_session() + + # obtains verifier + try: + oauth.parse_authorization_response(request.url) + except TokenMissing: + return # authorization denied + + # obtains access token + token = oauth.fetch_access_token(self.access_token_url) + return OAuth1Response(token) + + def make_oauth_session(self, **kwargs): + oauth = self.session_class( + self.consumer_key, client_secret=self.consumer_secret, **kwargs) + return oauth + + +class OAuth2Application(BaseApplication): + """The remote application for OAuth 2.""" + + session_class = OAuth2Session + + access_token_url = OAuthProperty('access_token_url') + authorization_url = OAuthProperty('authorization_url') + refresh_token_url = OAuthProperty('refresh_token_url', default='') + + client_id = OAuthProperty('client_id') + client_secret = OAuthProperty('client_secret') + scope = OAuthProperty('scope', default=None) + + compliance_fixes = OAuthProperty('compliance_fixes', default=None) + + _session_state = WebSessionData('state') + _session_redirect_url = WebSessionData('redir') + + def make_client(self, token): + """Creates a client with specific access token dictionary. + + :param token: a dictionary of access token response. + :returns: a :class:`requests_oauthlib.oauth2_session.OAuth2Session` + object. + """ + return self.make_oauth_session(token=token) + + def tokensaver(self, fn): + """A decorator to register a callback function for saving refreshed + token while the old token has expired and the ``refresh_token_url`` has + been specified. + + It is necessary for using the automatic refresh mechanism. + + :param fn: the callback function with ``token`` as its unique argument. + """ + self._tokensaver = fn + return fn + + def authorize(self, callback_uri, code=302, **kwargs): + oauth = self.make_oauth_session(redirect_uri=callback_uri) + authorization_url, state = oauth.authorization_url( + self.authorization_url, **kwargs) + self._session_state = state + self._session_redirect_url = callback_uri + return redirect(authorization_url, code) + + def authorized_response(self): + oauth = self.make_oauth_session( + state=self._session_state, + redirect_uri=self._session_redirect_url) + del self._session_state + del self._session_redirect_url + + with self.insecure_transport(): + try: + token = oauth.fetch_token( + self.access_token_url, client_secret=self.client_secret, + authorization_response=request.url) + except MissingCodeError: + return + + return OAuth2Response(token) + + def make_oauth_session(self, **kwargs): + kwargs.setdefault('scope', self.scope) + + # configures automatic token refresh if possible + if self.refresh_token_url: + if not hasattr(self, '_tokensaver'): + raise RuntimeError('missing tokensaver') + kwargs.setdefault('auto_refresh_url', self.refresh_token_url) + kwargs.setdefault('auto_refresh_kwargs', { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + }) + kwargs.setdefault('token_updater', self._tokensaver) + + # creates session + oauth = self.session_class(self.client_id, **kwargs) + + # patches session + compliance_fixes = self.compliance_fixes + if compliance_fixes is not None: + if compliance_fixes.startswith('.'): + compliance_fixes = \ + 'requests_oauthlib.compliance_fixes' + compliance_fixes + apply_fixes = import_string(compliance_fixes) + oauth = apply_fixes(oauth) + + return oauth + + @contextlib.contextmanager + def insecure_transport(self): + """Creates a context to enable the oauthlib environment variable in + order to debug with insecure transport. + """ + origin = os.environ.get('OAUTHLIB_INSECURE_TRANSPORT') + if current_app.debug or current_app.testing: + try: + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + yield + finally: + if origin: + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = origin + else: + os.environ.pop('OAUTHLIB_INSECURE_TRANSPORT', None) + else: + if origin: + warnings.warn( + 'OAUTHLIB_INSECURE_TRANSPORT has been found in os.environ ' + 'but the app is not running in debug mode or testing mode.' + ' It may put you in danger of the Man-in-the-middle attack' + ' while using OAuth 2.', RuntimeWarning) + yield + + +def _hash_token(application, token): + """Creates a hashable object for given token then we could use it as a + dictionary key. + """ + if isinstance(token, dict): + hashed_token = tuple(sorted(token.items())) + elif isinstance(token, tuple): + hashed_token = token + else: + raise TypeError('%r is unknown type of token' % token) + + return (application.__class__.__name__, application.name, hashed_token) diff --git a/flask_oauthlib/contrib/client/descriptor.py b/flask_oauthlib/contrib/client/descriptor.py new file mode 100644 index 00000000..e3658299 --- /dev/null +++ b/flask_oauthlib/contrib/client/descriptor.py @@ -0,0 +1,69 @@ +from flask import current_app, session + + +__all__ = ['OAuthProperty', 'WebSessionData'] + + +class OAuthProperty(object): + """The property which providing config item to remote applications. + + The application classes must have ``name`` to identity themselves. + """ + + _missing = object() + + def __init__(self, name, default=_missing): + self.name = name + self.default = default + + def __get__(self, instance, owner): + if instance is None: + return self + + # instance resources + instance_namespace = vars(instance) + instance_ident = instance.name + + # gets from instance namespace + if self.name in instance_namespace: + return instance_namespace[self.name] + + # gets from app config (or default value) + config_name = '{0}_{1}'.format(instance_ident, self.name).upper() + if config_name not in current_app.config: + if self.default is not self._missing: + return self.default + exception_message = ( + '{0!r} missing {1} \n\n You need to provide it in arguments' + ' `{0.__class__.__name__}(..., {1}="foobar", ...)` or in ' + 'app.config `{2}`').format(instance, self.name, config_name) + raise RuntimeError(exception_message) + return current_app.config[config_name] + + def __set__(self, instance, value): + # assigns into instance namespace + instance_namespace = vars(instance) + instance_namespace[self.name] = value + + +class WebSessionData(object): + """The property which providing accessing of Flask session.""" + + key_format = '_oauth_{0}_{1}' + + def __init__(self, ident): + self.ident = ident + + def make_key(self, instance): + return self.key_format.format(instance.name, self.ident) + + def __get__(self, instance, owner): + if instance is None: + return self + return session.get(self.make_key(instance)) + + def __set__(self, instance, value): + session[self.make_key(instance)] = value + + def __delete__(self, instance): + session.pop(self.make_key(instance), None) diff --git a/flask_oauthlib/contrib/client/exceptions.py b/flask_oauthlib/contrib/client/exceptions.py new file mode 100644 index 00000000..79487284 --- /dev/null +++ b/flask_oauthlib/contrib/client/exceptions.py @@ -0,0 +1,9 @@ +__all__ = ['OAuthException', 'AccessTokenNotFound'] + + +class OAuthException(Exception): + pass + + +class AccessTokenNotFound(OAuthException): + pass diff --git a/flask_oauthlib/contrib/client/signals.py b/flask_oauthlib/contrib/client/signals.py new file mode 100644 index 00000000..13df6e33 --- /dev/null +++ b/flask_oauthlib/contrib/client/signals.py @@ -0,0 +1,6 @@ +from flask.signals import Namespace + +__all__ = ['request_token_fetched'] + +_signals = Namespace() +request_token_fetched = _signals.signal('request-token-fetched') diff --git a/flask_oauthlib/contrib/client/structure.py b/flask_oauthlib/contrib/client/structure.py new file mode 100644 index 00000000..65f75e69 --- /dev/null +++ b/flask_oauthlib/contrib/client/structure.py @@ -0,0 +1,17 @@ +import operator + + +__all__ = ['OAuth1Response', 'OAuth2Response'] + + +class OAuth1Response(dict): + token = property(operator.itemgetter('oauth_token')) + token_secret = property(operator.itemgetter('oauth_token_secret')) + + +class OAuth2Response(dict): + access_token = property(operator.itemgetter('access_token')) + refresh_token = property(operator.itemgetter('refresh_token')) + token_type = property(operator.itemgetter('token_type')) + expires_in = property(operator.itemgetter('expires_in')) + expires_at = property(operator.itemgetter('expires_at')) diff --git a/flask_oauthlib/contrib/oauth2.py b/flask_oauthlib/contrib/oauth2.py index ec908fcd..715dc93e 100644 --- a/flask_oauthlib/contrib/oauth2.py +++ b/flask_oauthlib/contrib/oauth2.py @@ -175,7 +175,7 @@ def set_token(token, request, *args, **kwargs): provider.clientgetter(client_binding.get) if token: - token_binding = TokenBinding(token, session) + token_binding = TokenBinding(token, session, current_user) provider.tokengetter(token_binding.get) provider.tokensetter(token_binding.set) @@ -240,6 +240,9 @@ class TokenBinding(BaseBinding): """Object use by SQLAlchemyBinding to register the token getter and setter """ + def __init__(self, model, session, current_user=None): + self.current_user = current_user + super(TokenBinding, self).__init__(model, session) def get(self, access_token=None, refresh_token=None): """returns a Token object with the given access token or refresh token @@ -260,8 +263,17 @@ def set(self, token, request, *args, **kwargs): :param token: token object :param request: OAuthlib request object """ - tokens = self.query.filter_by(client_id=request.client.client_id, - user_id=request.user.id).all() + if hasattr(request, 'user') and request.user: + user = request.user + elif self.current_user: + # for implicit token + user = self.current_user() + + client = request.client + + tokens = self.query.filter_by( + client_id=client.client_id, + user_id=user.id).all() if tokens: for tk in tokens: self.session.delete(tk) @@ -272,8 +284,8 @@ def set(self, token, request, *args, **kwargs): tok = self.model(**token) tok.expires = expires - tok.client_id = request.client.client_id - tok.user_id = request.user.id + tok.client_id = client.client_id + tok.user_id = user.id self.session.add(tok) self.session.commit() diff --git a/flask_oauthlib/provider/__init__.py b/flask_oauthlib/provider/__init__.py index 050a2655..1b0aee04 100644 --- a/flask_oauthlib/provider/__init__.py +++ b/flask_oauthlib/provider/__init__.py @@ -5,7 +5,7 @@ Implemnts OAuth1 and OAuth2 providers support for Flask. - :copyright: (c) 2013 by Hsiaoming Yang. + :copyright: (c) 2013 - 2014 by Hsiaoming Yang. """ # flake8: noqa diff --git a/flask_oauthlib/provider/oauth1.py b/flask_oauthlib/provider/oauth1.py index bcfe9e48..ec517e19 100644 --- a/flask_oauthlib/provider/oauth1.py +++ b/flask_oauthlib/provider/oauth1.py @@ -5,18 +5,18 @@ Implemnts OAuth1 provider support for Flask. - :copyright: (c) 2013 by Hsiaoming Yang. + :copyright: (c) 2013 - 2014 by Hsiaoming Yang. """ import logging from functools import wraps -from werkzeug import cached_property +from werkzeug.utils import cached_property from flask import request, redirect, url_for from flask import make_response, abort from oauthlib.oauth1 import RequestValidator from oauthlib.oauth1 import WebApplicationServer as Server from oauthlib.oauth1 import SIGNATURE_HMAC, SIGNATURE_RSA -from oauthlib.common import to_unicode, add_params_to_uri +from oauthlib.common import to_unicode, add_params_to_uri, urlencode from oauthlib.oauth1.rfc5849 import errors from ..utils import extract_params, create_response @@ -49,8 +49,8 @@ def create_app(): @app.route('/api/user') @oauth.require_oauth('email', 'username') - def user(oauth): - return jsonify(oauth.user) + def user(): + return jsonify(request.oauth.user) """ def __init__(self, app=None): @@ -151,6 +151,7 @@ def limit_client_request(): track_request(client) """ self._before_request_funcs.append(f) + return f def after_request(self, f): """Register functions to be invoked after accessing the resource. @@ -165,6 +166,7 @@ def valid_after_request(valid, oauth): return valid, oauth """ self._after_request_funcs.append(f) + return f def clientgetter(self, f): """Register a function as the client getter. @@ -190,6 +192,7 @@ def get_client(client_key): return client """ self._clientgetter = f + return f def tokengetter(self, f): """Register a function as the access token getter. @@ -210,6 +213,7 @@ def get_access_token(client_key, token): return AccessToken.get(client_key=client_key, token=token) """ self._tokengetter = f + return f def tokensetter(self, f): """Register a function as the access token setter. @@ -243,6 +247,7 @@ def save_access_token(token, request): - request_token: Requst token for exchanging this access token """ self._tokensetter = f + return f def grantgetter(self, f): """Register a function as the request token getter. @@ -263,6 +268,7 @@ def get_request_token(token): return RequestToken.get(token=token) """ self._grantgetter = f + return f def grantsetter(self, f): """Register a function as the request token setter. @@ -281,6 +287,7 @@ def save_request_token(token, request): return data.save() """ self._grantsetter = f + return f def noncegetter(self, f): """Register a function as the nonce and timestamp getter. @@ -301,6 +308,7 @@ def get_nonce(client_key, timestamp, nonce, request_token, return Nonce.get("...") """ self._noncegetter = f + return f def noncesetter(self, f): """Register a function as the nonce and timestamp setter. @@ -317,6 +325,7 @@ def save_nonce(client_key, timestamp, nonce, request_token, if you put timestamp and nonce object in a cache. """ self._noncesetter = f + return f def verifiergetter(self, f): """Register a function as the verifier getter. @@ -335,6 +344,7 @@ def load_verifier(verifier, token): return data """ self._verifiergetter = f + return f def verifiersetter(self, f): """Register a function as the verifier setter. @@ -356,6 +366,7 @@ def save_verifier(verifier, token, *args, **kwargs): return data.save() """ self._verifiersetter = f + return f def authorize_handler(self, f): """Authorization handler decorator. @@ -480,20 +491,29 @@ def decorated(*args, **kwargs): for func in self._before_request_funcs: func() + if hasattr(request, 'oauth') and request.oauth: + return f(*args, **kwargs) + server = self.server uri, http_method, body, headers = extract_params() - valid, req = server.validate_protected_resource_request( - uri, http_method, body, headers, realms - ) - + try: + valid, req = server.validate_protected_resource_request( + uri, http_method, body, headers, realms + ) + except Exception as e: + log.warn('Exception: %r', e) + e.urlencoded = urlencode([('error', 'unknown')]) + e.status_code = 400 + return _error_response(e) for func in self._after_request_funcs: valid, req = func(valid, req) if not valid: - return abort(403) + return abort(401) # alias user for convenience req.user = req.access_token.user - return f(*((req,) + args), **kwargs) + request.oauth = req + return f(*args, **kwargs) return decorated return wrapper @@ -556,7 +576,7 @@ def client_key_length(self): ) @property - def reqeust_token_length(self): + def request_token_length(self): return self._config.get( 'OAUTH1_PROVIDER_KEY_LENGTH', (20, 30) diff --git a/flask_oauthlib/provider/oauth2.py b/flask_oauthlib/provider/oauth2.py index 831cc447..14a57af2 100644 --- a/flask_oauthlib/provider/oauth2.py +++ b/flask_oauthlib/provider/oauth2.py @@ -5,7 +5,7 @@ Implemnts OAuth2 provider support for Flask. - :copyright: (c) 2013 by Hsiaoming Yang. + :copyright: (c) 2013 - 2014 by Hsiaoming Yang. """ import os @@ -14,11 +14,10 @@ from functools import wraps from flask import request, url_for from flask import redirect, abort -from werkzeug import cached_property -from werkzeug.utils import import_string +from werkzeug.utils import import_string, cached_property from oauthlib import oauth2 from oauthlib.oauth2 import RequestValidator, Server -from oauthlib.common import to_unicode +from oauthlib.common import to_unicode, add_params_to_uri from ..utils import extract_params, decode_base64, create_response __all__ = ('OAuth2Provider', 'OAuth2RequestValidator') @@ -65,13 +64,16 @@ def create_app(): @app.route('/api/user') @oauth.require_oauth('email', 'username') - def user(oauth): - return jsonify(oauth.user) + def user(): + return jsonify(request.oauth.user) """ - def __init__(self, app=None): + def __init__(self, app=None, validator_class=None): self._before_request_funcs = [] self._after_request_funcs = [] + self._exception_handler = None + self._invalid_response = None + self._validator_class = validator_class if app: self.init_app(app) @@ -84,6 +86,13 @@ def init_app(self, app): app.extensions = getattr(app, 'extensions', {}) app.extensions['oauthlib.provider.oauth2'] = self + def _on_exception(self, error, redirect_content=None): + + if self._exception_handler: + return self._exception_handler(error, redirect_content) + else: + return redirect(redirect_content) + @cached_property def error_uri(self): """The error page URI. @@ -130,11 +139,18 @@ def validate_client_id(self, client_id): if token_generator and not callable(token_generator): token_generator = import_string(token_generator) + refresh_token_generator = self.app.config.get( + 'OAUTH2_PROVIDER_REFRESH_TOKEN_GENERATOR', None + ) + if refresh_token_generator and not callable(refresh_token_generator): + refresh_token_generator = import_string(refresh_token_generator) + if hasattr(self, '_validator'): return Server( self._validator, token_expires_in=expires_in, token_generator=token_generator, + refresh_token_generator=refresh_token_generator, ) if hasattr(self, '_clientgetter') and \ @@ -147,7 +163,10 @@ def validate_client_id(self, client_id): if hasattr(self, '_usergetter'): usergetter = self._usergetter - validator = OAuth2RequestValidator( + validator_class = self._validator_class + if validator_class is None: + validator_class = OAuth2RequestValidator + validator = validator_class( clientgetter=self._clientgetter, tokengetter=self._tokengetter, grantgetter=self._grantgetter, @@ -160,6 +179,7 @@ def validate_client_id(self, client_id): validator, token_expires_in=expires_in, token_generator=token_generator, + refresh_token_generator=refresh_token_generator, ) raise RuntimeError('application not bound to required getters') @@ -182,6 +202,7 @@ def limit_client_request(): track_request(client) """ self._before_request_funcs.append(f) + return f def after_request(self, f): """Register functions to be invoked after accessing the resource. @@ -196,6 +217,51 @@ def valid_after_request(valid, oauth): return valid, oauth """ self._after_request_funcs.append(f) + return f + + def exception_handler(self, f): + """Register a function as custom exception handler. + + **As the default error handling is leaking error to the client, it is + STRONGLY RECOMMENDED to implement your own handler to mask + the server side errors in production environment.** + + When an error occur during execution, we can + handle the error with with the registered function. The function + accepts two parameters: + - error: the error raised + - redirect_content: the content used in the redirect by default + + usage with the flask error handler :: + @oauth.exception_handler + def custom_exception_handler(error, *args): + raise error + + @app.errorhandler(Exception) + def all_exception_handler(*args): + # any treatment you need for the error + return "Server error", 500 + + If no function is registered, it will do a redirect with ``redirect_content`` as content. + """ + self._exception_handler = f + return f + + def invalid_response(self, f): + """Register a function for responsing with invalid request. + + When an invalid request proceeds to :meth:`require_oauth`, we can + handle the request with the registered function. The function + accepts one parameter, which is an oauthlib Request object:: + + @oauth.invalid_response + def invalid_require_oauth(req): + return jsonify(message=req.error_message), 401 + + If no function is registered, it will return with ``abort(401)``. + """ + self._invalid_response = f + return f def clientgetter(self, f): """Register a function as the client getter. @@ -205,7 +271,7 @@ def clientgetter(self, f): - client_id: A random string - client_secret: A random string - - client_type: A string represents if it is `confidential` + - is_confidential: A bool represents if it is confidential - redirect_uris: A list of redirect uris - default_redirect_uri: One of the redirect uris - default_scopes: Default scopes of the client @@ -225,19 +291,30 @@ def get_client(client_id): return client """ self._clientgetter = f + return f def usergetter(self, f): """Register a function as the user getter. - This decorator is only required for password credential + This decorator is only required for **password credential** authorization:: @oauth.usergetter - def get_user(username=username, password=password, + def get_user(username, password, client, request, *args, **kwargs): - return get_user_by_username(username, password) + # client: current request client + if not client.has_password_credential_permission: + return None + user = User.get_user_by_username(username) + if not user.validate_password(password): + return None + + # parameter `request` is an OAuthlib Request object. + # maybe you will need it somewhere + return user """ self._usergetter = f + return f def tokengetter(self, f): """Register a function as the token getter. @@ -264,6 +341,7 @@ def bearer_token(access_token=None, refresh_token=None): return None """ self._tokengetter = f + return f def tokensetter(self, f): """Register a function to save the bearer token. @@ -288,6 +366,7 @@ def set_token(token, request, *args, **kwargs): client object. """ self._tokensetter = f + return f def grantgetter(self, f): """Register a function as the grant getter. @@ -303,6 +382,7 @@ def grant(client_id, code): - delete: A function to delete itself """ self._grantgetter = f + return f def grantsetter(self, f): """Register a function to save the grant code. @@ -314,6 +394,7 @@ def set_grant(client_id, code, request, *args, **kwargs): save_grant(client_id, code, request.user, request.scopes) """ self._grantsetter = f + return f def authorize_handler(self, f): """Authorization handler decorator. @@ -337,8 +418,8 @@ def decorated(*args, **kwargs): server = self.server uri, http_method, body, headers = extract_params() - if request.method == 'GET': - redirect_uri = request.args.get('redirect_uri', None) + if request.method in ('GET', 'HEAD'): + redirect_uri = request.args.get('redirect_uri', self.error_uri) log.debug('Found redirect_uri %s.', redirect_uri) try: ret = server.validate_authorization_request( @@ -347,22 +428,55 @@ def decorated(*args, **kwargs): scopes, credentials = ret kwargs['scopes'] = scopes kwargs.update(credentials) - return f(*args, **kwargs) except oauth2.FatalClientError as e: - log.debug('Fatal client error %r', e) - return redirect(e.in_uri(self.error_uri)) - - if request.method == 'POST': - redirect_uri = request.values.get('redirect_uri', None) - if not f(*args, **kwargs): - # denied by user - e = oauth2.AccessDeniedError() - return redirect(e.in_uri(redirect_uri)) - return self.confirm_authorization_request() + log.debug('Fatal client error %r', e, exc_info=True) + return self._on_exception(e, e.in_uri(self.error_uri)) + except oauth2.OAuth2Error as e: + log.debug('OAuth2Error: %r', e, exc_info=True) + # on auth error, we should preserve state if it's present according to RFC 6749 + state = request.values.get('state') + if state and not e.state: + e.state = state # set e.state so e.in_uri() can add the state query parameter to redirect uri + return self._on_exception(e, e.in_uri(redirect_uri)) + + except Exception as e: + log.exception(e) + return self._on_exception(e, add_params_to_uri( + self.error_uri, {'error': str(e)} + )) + + else: + redirect_uri = request.values.get( + 'redirect_uri', self.error_uri + ) + + try: + rv = f(*args, **kwargs) + except oauth2.FatalClientError as e: + log.debug('Fatal client error %r', e, exc_info=True) + return self._on_exception(e, e.in_uri(self.error_uri)) + except oauth2.OAuth2Error as e: + log.debug('OAuth2Error: %r', e, exc_info=True) + # on auth error, we should preserve state if it's present according to RFC 6749 + state = request.values.get('state') + if state and not e.state: + e.state = state # set e.state so e.in_uri() can add the state query parameter to redirect uri + return self._on_exception(e, e.in_uri(redirect_uri)) + + if not isinstance(rv, bool): + # if is a response or redirect + return rv + + if not rv: + # denied by user + e = oauth2.AccessDeniedError(state=request.values.get('state')) + return self._on_exception(e, e.in_uri(redirect_uri)) + + return self.confirm_authorization_request() return decorated def confirm_authorization_request(self): - """When consumer confirm the authrozation.""" + """When consumer confirm the authorization.""" server = self.server scope = request.values.get('scope') or '' scopes = scope.split() @@ -383,9 +497,38 @@ def confirm_authorization_request(self): log.debug('Authorization successful.') return create_response(*ret) except oauth2.FatalClientError as e: - return redirect(e.in_uri(self.error_uri)) + log.debug('Fatal client error %r', e, exc_info=True) + return self._on_exception(e, e.in_uri(self.error_uri)) except oauth2.OAuth2Error as e: - return redirect(e.in_uri(redirect_uri)) + log.debug('OAuth2Error: %r', e, exc_info=True) + + # on auth error, we should preserve state if it's present according to RFC 6749 + state = request.values.get('state') + if state and not e.state: + e.state = state # set e.state so e.in_uri() can add the state query parameter to redirect uri + return self._on_exception(e, e.in_uri(redirect_uri or self.error_uri)) + except Exception as e: + log.exception(e) + return self._on_exception(e, add_params_to_uri( + self.error_uri, {'error': str(e)} + )) + + def verify_request(self, scopes): + """Verify current request, get the oauth data. + + If you can't use the ``require_oauth`` decorator, you can fetch + the data in your request body:: + + def your_handler(): + valid, req = oauth.verify_request(['email']) + if valid: + return jsonify(user=req.user) + return jsonify(status='error') + """ + uri, http_method, body, headers = extract_params() + return self.server.verify_request( + uri, http_method, body, headers, scopes + ) def token_handler(self, f): """Access/refresh token handler decorator. @@ -413,6 +556,38 @@ def decorated(*args, **kwargs): return create_response(*ret) return decorated + def revoke_handler(self, f): + """Access/refresh token revoke decorator. + + Any return value by the decorated function will get discarded as + defined in [`RFC7009`_]. + + You can control the access method with the standard flask routing + mechanism, as per [`RFC7009`_] it is recommended to only allow + the `POST` method:: + + @app.route('/oauth/revoke', methods=['POST']) + @oauth.revoke_handler + def revoke_token(): + pass + + .. _`RFC7009`: http://tools.ietf.org/html/rfc7009 + """ + @wraps(f) + def decorated(*args, **kwargs): + server = self.server + + token = request.values.get('token') + request.token_type_hint = request.values.get('token_type_hint') + if token: + request.token = token + + uri, http_method, body, headers = extract_params() + ret = server.create_revocation_response( + uri, headers=headers, body=body, http_method=http_method) + return create_response(*ret) + return decorated + def require_oauth(self, *scopes): """Protect resource with specified scopes.""" def wrapper(f): @@ -421,18 +596,20 @@ def decorated(*args, **kwargs): for func in self._before_request_funcs: func() - server = self.server - uri, http_method, body, headers = extract_params() - valid, req = server.verify_request( - uri, http_method, body, headers, scopes - ) + if hasattr(request, 'oauth') and request.oauth: + return f(*args, **kwargs) + + valid, req = self.verify_request(scopes) for func in self._after_request_funcs: valid, req = func(valid, req) if not valid: - return abort(403) - return f(*((req,) + args), **kwargs) + if self._invalid_response: + return self._invalid_response(req) + return abort(401) + request.oauth = req + return f(*args, **kwargs) return decorated return wrapper @@ -455,6 +632,63 @@ def __init__(self, clientgetter, tokengetter, grantgetter, self._grantgetter = grantgetter self._grantsetter = grantsetter + def _get_client_creds_from_request(self, request): + """Return client credentials based on the current request. + + According to the rfc6749, client MAY use the HTTP Basic authentication + scheme as defined in [RFC2617] to authenticate with the authorization + server. The client identifier is encoded using the + "application/x-www-form-urlencoded" encoding algorithm per Appendix B, + and the encoded value is used as the username; the client password is + encoded using the same algorithm and used as the password. The + authorization server MUST support the HTTP Basic authentication scheme + for authenticating clients that were issued a client password. + See `Section 2.3.1`_. + + .. _`Section 2.3.1`: https://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + if request.client_id is not None: + return request.client_id, request.client_secret + + auth = request.headers.get('Authorization') + # If Werkzeug successfully parsed the Authorization header, + # `extract_params` helper will replace the header with a parsed dict, + # otherwise, there is nothing useful in the header and we just skip it. + if isinstance(auth, dict): + return auth['username'], auth['password'] + + return None, None + + def client_authentication_required(self, request, *args, **kwargs): + """Determine if client authentication is required for current request. + + According to the rfc6749, client authentication is required in the + following cases: + + Resource Owner Password Credentials Grant: see `Section 4.3.2`_. + Authorization Code Grant: see `Section 4.1.3`_. + Refresh Token Grant: see `Section 6`_. + + .. _`Section 4.3.2`: http://tools.ietf.org/html/rfc6749#section-4.3.2 + .. _`Section 4.1.3`: http://tools.ietf.org/html/rfc6749#section-4.1.3 + .. _`Section 6`: http://tools.ietf.org/html/rfc6749#section-6 + """ + def is_confidential(client): + if hasattr(client, 'is_confidential'): + return client.is_confidential + client_type = getattr(client, 'client_type', None) + if client_type: + return client_type == 'confidential' + return True + + grant_types = ('password', 'authorization_code', 'refresh_token') + client_id, _ = self._get_client_creds_from_request(request) + if client_id and request.grant_type in grant_types: + client = self._clientgetter(client_id) + if client: + return is_confidential(client) + return False + def authenticate_client(self, request, *args, **kwargs): """Authenticate itself in other means. @@ -462,20 +696,8 @@ def authenticate_client(self, request, *args, **kwargs): .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 """ - auth = request.headers.get('Authorization', None) - log.debug('Authenticate client %r', auth) - if auth: - try: - _, s = auth.split(' ') - client_id, client_secret = decode_base64(s).split(':') - client_id = to_unicode(client_id, 'utf-8') - client_secret = to_unicode(client_secret, 'utf-8') - except Exception as e: - log.debug('Authenticate client failed with exception: %r', e) - return False - else: - client_id = request.client_id - client_secret = request.client_secret + client_id, client_secret = self._get_client_creds_from_request(request) + log.debug('Authenticate client %r', client_id) client = self._clientgetter(client_id) if not client: @@ -483,13 +705,13 @@ def authenticate_client(self, request, *args, **kwargs): return False request.client = client - if client.client_secret != client_secret: + + # http://tools.ietf.org/html/rfc6749#section-2 + # The client MAY omit the parameter if the client secret is an empty string. + if hasattr(client, 'client_secret') and client.client_secret != client_secret: log.debug('Authenticate client failed, secret not match.') return False - if client.client_type != 'confidential': - log.debug('Authenticate client failed, not confidential.') - return False log.debug('Authenticate client success.') return True @@ -499,6 +721,9 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): :param client_id: Client ID of the non-confidential client :param request: The Request object passed by oauthlib """ + if client_id is None: + client_id, _ = self._get_client_creds_from_request(request) + log.debug('Authenticate client %r.', client_id) client = request.client or self._clientgetter(client_id) if not client: @@ -507,12 +732,6 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): # attach client on request for convenience request.client = client - - # authenticate non-confidential client_type only - # most of the clients are of public client_type - if client.client_type == 'confidential': - log.debug('Authenticate client failed, confidential client.') - return False return True def confirm_redirect_uri(self, client_id, code, redirect_uri, client, @@ -524,9 +743,10 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, add a `validate_redirect_uri` function on grant for a customized validation. """ + client = client or self._clientgetter(client_id) log.debug('Confirm redirect uri for client %r and code %r.', - client_id, code) - grant = self._grantgetter(client_id=client_id, code=code) + client.client_id, code) + grant = self._grantgetter(client_id=client.client_id, code=code) if not grant: log.debug('Grant not found.') return False @@ -535,7 +755,8 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, log.debug('Compare redirect uri for grant %r and %r.', grant.redirect_uri, redirect_uri) - if os.environ.get('DEBUG') and redirect_uri is None: + testing = 'OAUTHLIB_INSECURE_TRANSPORT' in os.environ + if testing and redirect_uri is None: # For testing return True @@ -628,17 +849,24 @@ def validate_bearer_token(self, token, scopes, request): log.debug('Validate bearer token %r', token) tok = self._tokengetter(access_token=token) if not tok: - log.debug('Bearer token not found.') + msg = 'Bearer token not found.' + request.error_message = msg + log.debug(msg) return False # validate expires - if datetime.datetime.utcnow() > tok.expires: - log.debug('Bearer token is expired.') + if tok.expires is not None and \ + datetime.datetime.utcnow() > tok.expires: + msg = 'Bearer token is expired.' + request.error_message = msg + log.debug(msg) return False # validate scopes - if not set(tok.scopes).issuperset(set(scopes)): - log.debug('Bearer token scope not valid.') + if scopes and not set(tok.scopes) & set(scopes): + msg = 'Bearer token scope not valid.' + request.error_message = msg + log.debug(msg) return False request.access_token = tok @@ -663,10 +891,11 @@ def validate_client_id(self, client_id, request, *args, **kwargs): def validate_code(self, client_id, code, client, request, *args, **kwargs): """Ensure the grant code is valid.""" + client = client or self._clientgetter(client_id) log.debug( - 'Validate code for client %r and code %r', client_id, code + 'Validate code for client %r and code %r', client.client_id, code ) - grant = self._grantgetter(client_id=client_id, code=code) + grant = self._grantgetter(client_id=client.client_id, code=code) if not grant: log.debug('Grant not found.') return False @@ -696,19 +925,25 @@ def validate_grant_type(self, client_id, grant_type, client, request, log.debug('Password credential authorization is disabled.') return False - if grant_type not in ('authorization_code', 'password', - 'client_credentials', 'refresh_token'): - return False + default_grant_types = ( + 'authorization_code', 'password', + 'client_credentials', 'refresh_token', + ) + # Grant type is allowed if it is part of the 'allowed_grant_types' + # of the selected client or if it is one of the default grant types if hasattr(client, 'allowed_grant_types'): - return grant_type in client.allowed_grant_types + if grant_type not in client.allowed_grant_types: + return False + else: + if grant_type not in default_grant_types: + return False if grant_type == 'client_credentials': - if hasattr(client, 'user'): - request.user = client.user - return True - log.debug('Client should has a user property') - return False + if not hasattr(client, 'user'): + log.debug('Client should have a user property') + return False + request.user = client.user return True @@ -721,7 +956,7 @@ def validate_redirect_uri(self, client_id, redirect_uri, request, redirect_uris strictly, you can add a `validate_redirect_uri` function on grant for a customized validation. """ - request.client = request.client = self._clientgetter(client_id) + request.client = request.client or self._clientgetter(client_id) client = request.client if hasattr(client, 'validate_redirect_uri'): return client.validate_redirect_uri(redirect_uri) @@ -763,11 +998,9 @@ def validate_response_type(self, client_id, response_type, client, request, def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """Ensure the client is authorized access to requested scopes.""" - if set(client.default_scopes).issuperset(set(scopes)): - return True if hasattr(client, 'validate_scopes'): return client.validate_scopes(scopes) - return True + return set(client.default_scopes).issuperset(set(scopes)) def validate_user(self, username, password, client, request, *args, **kwargs): @@ -775,8 +1008,7 @@ def validate_user(self, username, password, client, request, Attach user object on request for later using. """ - log.debug('Validating username %r and password %r', - username, password) + log.debug('Validating username %r and its password', username) if self._usergetter is not None: user = self._usergetter( username, password, client, request, *args, **kwargs @@ -787,3 +1019,24 @@ def validate_user(self, username, password, client, request, return False log.debug('Password credential authorization is disabled.') return False + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """Revoke an access or refresh token. + """ + if token_type_hint: + tok = self._tokengetter(**{token_type_hint: token}) + else: + tok = self._tokengetter(access_token=token) + if not tok: + tok = self._tokengetter(refresh_token=token) + + if tok: + request.client_id = tok.client_id + request.user = tok.user + tok.delete() + return True + + msg = 'Invalid token supplied.' + log.debug(msg) + request.error_message = msg + return False diff --git a/flask_oauthlib/utils.py b/flask_oauthlib/utils.py index 3349d064..a36404df 100644 --- a/flask_oauthlib/utils.py +++ b/flask_oauthlib/utils.py @@ -2,36 +2,61 @@ import base64 from flask import request, Response -from oauthlib.common import to_unicode, bytes_type +from oauthlib.common import to_unicode + + +def _get_uri_from_request(request): + """ + The uri returned from request.uri is not properly urlencoded + (sometimes it's partially urldecoded) This is a weird hack to get + werkzeug to return the proper urlencoded string uri + """ + uri = request.base_url + if request.query_string: + uri += '?' + request.query_string.decode('utf-8') + return uri def extract_params(): """Extract request params.""" - uri = request.url + + uri = _get_uri_from_request(request) http_method = request.method headers = dict(request.headers) if 'wsgi.input' in headers: del headers['wsgi.input'] if 'wsgi.errors' in headers: del headers['wsgi.errors'] + # Werkzeug, and subsequently Flask provide a safe Authorization header + # parsing, so we just replace the Authorization header with the extraced + # info if it was successfully parsed. + if request.authorization: + headers['Authorization'] = str(request.authorization) body = request.form.to_dict() return uri, http_method, body, headers -def decode_base64(text): +def to_bytes(text, encoding='utf-8'): + """Make sure text is bytes type.""" + if not text: + return text + if not isinstance(text, bytes): + text = text.encode(encoding) + return text + + +def decode_base64(text, encoding='utf-8'): """Decode base64 string.""" - # make sure it is bytes - if not isinstance(text, bytes_type): - text = text.encode('utf-8') - return to_unicode(base64.b64decode(text), 'utf-8') + text = to_bytes(text, encoding) + return to_unicode(base64.b64decode(text), encoding) def create_response(headers, body, status): """Create response class for Flask.""" response = Response(body or '') for k, v in headers.items(): - response.headers[k] = v + response.headers[str(k)] = v response.status_code = status return response diff --git a/requirements.txt b/requirements.txt index da38f09d..b7e68221 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -Flask -Mock -oauthlib -Flask-SQLAlchemy +Flask==3.0.0 +mock==2.0.0 +oauthlib==3.2.2 +requests-oauthlib==1.3.1 +Flask-SQLAlchemy==3.1.1 +cachelib==0.10.2 + diff --git a/setup.py b/setup.py index a31cd3dd..018c131e 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ def fread(filename): "flask_oauthlib", "flask_oauthlib.provider", "flask_oauthlib.contrib", + "flask_oauthlib.contrib.client", ], description="OAuthlib for Flask", zip_safe=False, @@ -42,12 +43,14 @@ def fread(filename): license='BSD', install_requires=[ 'Flask', - 'oauthlib>=0.6', + 'oauthlib>=3.0.0,<3.2.2', + 'requests-oauthlib>=1.0,<1.3.1', + 'cachelib', ], tests_require=['nose', 'Flask-SQLAlchemy', 'mock'], test_suite='nose.collector', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Intended Audience :: Developers', 'License :: OSI Approved', @@ -59,6 +62,9 @@ def fread(filename): 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', diff --git a/tests/_base.py b/tests/_base.py index 69e28196..c14bbb3a 100644 --- a/tests/_base.py +++ b/tests/_base.py @@ -1,5 +1,6 @@ # coding: utf-8 +import base64 import os import sys import tempfile @@ -17,7 +18,9 @@ python_version = 2 string_type = unicode -os.environ['DEBUG'] = 'true' +# os.environ['DEBUG'] = 'true' +# for oauthlib 0.6.3 +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = 'true' class BaseSuite(unittest.TestCase): @@ -29,7 +32,8 @@ def setUp(self): 'OAUTH1_PROVIDER_ENFORCE_SSL': False, 'OAUTH1_PROVIDER_KEY_LENGTH': (3, 30), 'OAUTH1_PROVIDER_REALMS': ['email', 'address'], - 'SQLALCHEMY_DATABASE_URI': 'sqlite:///%s' % self.db_file + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///%s' % self.db_file, + 'SQLALCHEMY_TRACK_MODIFICATIONS': False } app.config.update(config) @@ -89,6 +93,10 @@ def to_bytes(text): return text +def to_base64(text): + return to_unicode(base64.b64encode(to_bytes(text))) + + def clean_url(http://github.com/location): location = to_unicode(location) ret = urlparse(location) diff --git a/tests/oauth1/client.py b/tests/oauth1/client.py index 9c915fc1..d3e1ffce 100644 --- a/tests/oauth1/client.py +++ b/tests/oauth1/client.py @@ -42,8 +42,8 @@ def logout(): return redirect(url_for('index')) @app.route('/authorized') - @oauth.authorized_handler - def authorized(resp): + def authorized(): + resp = oauth.authorized_response() if resp is None: return 'Access denied: error=%s' % ( request.args['error'] diff --git a/tests/oauth1/server.py b/tests/oauth1/server.py index ef8421b7..917ed1e9 100644 --- a/tests/oauth1/server.py +++ b/tests/oauth1/server.py @@ -24,7 +24,7 @@ class User(db.Model): class Client(db.Model): - #id = db.Column(db.Integer, primary_key=True) + # id = db.Column(db.Integer, primary_key=True) # human readable name client_key = db.Column(db.String(40), primary_key=True) client_secret = db.Column(db.String(55), unique=True, index=True, @@ -239,17 +239,19 @@ def access_token(): @app.route('/api/email') @oauth.require_oauth('email') - def email_api(oauth): + def email_api(): + oauth = request.oauth return jsonify(email='me@oauth.net', username=oauth.user.username) @app.route('/api/address/') @oauth.require_oauth('address') - def address_api(oauth, city): + def address_api(city): + oauth = request.oauth return jsonify(address=city, username=oauth.user.username) @app.route('/api/method', methods=['GET', 'POST', 'PUT', 'DELETE']) @oauth.require_oauth() - def method_api(oauth): + def method_api(): return jsonify(method=request.method) return app diff --git a/tests/oauth1/test_oauth1.py b/tests/oauth1/test_oauth1.py index 51a57ce2..ed7d0fe0 100644 --- a/tests/oauth1/test_oauth1.py +++ b/tests/oauth1/test_oauth1.py @@ -60,7 +60,7 @@ def test_full_flow(self): assert 'email' in u(rv.data) rv = self.client.get('/address') - assert rv.status_code == 403 + assert rv.status_code == 401 rv = self.client.get('/method/post') assert 'POST' in u(rv.data) @@ -95,6 +95,7 @@ def test_invalid_request_token(self): }) assert 'error' in rv.location + auth_header = ( u'OAuth realm="%(realm)s",' u'oauth_nonce="97392753692390970531372987366",' diff --git a/tests/oauth2/client.py b/tests/oauth2/client.py index ae62b2c0..39cbb0bb 100644 --- a/tests/oauth2/client.py +++ b/tests/oauth2/client.py @@ -12,7 +12,7 @@ def create_client(app): request_token_params={'scope': 'email'}, base_url='http://127.0.0.1:5000/api/', request_token_url=None, - access_token_method='GET', + access_token_method='POST', access_token_url='http://127.0.0.1:5000/oauth/token', authorize_url='http://127.0.0.1:5000/oauth/authorize' ) @@ -34,8 +34,8 @@ def logout(): return redirect(url_for('index')) @app.route('/authorized') - @remote.authorized_handler - def authorized(resp): + def authorized(): + resp = remote.authorized_response() if resp is None: return 'Access denied: error=%s' % ( request.args['error'] @@ -48,7 +48,7 @@ def authorized(resp): @app.route('/client') def client_method(): ret = remote.get("client") - if ret.status not in (200,201): + if ret.status not in (200, 201): return abort(ret.status) return ret.raw_data @@ -56,7 +56,7 @@ def client_method(): def address(): ret = remote.get('address/hangzhou') if ret.status not in (200, 201): - return abort(ret.status) + return ret.raw_data, ret.status return ret.raw_data @app.route('/method/') @@ -74,7 +74,7 @@ def get_oauth_token(): if __name__ == '__main__': import os - os.environ['DEBUG'] = 'true' + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = 'true' # DEBUG=1 python oauth2_client.py app = Flask(__name__) app.debug = True diff --git a/tests/oauth2/server.py b/tests/oauth2/server.py index ee70fb90..6a1416b1 100644 --- a/tests/oauth2/server.py +++ b/tests/oauth2/server.py @@ -1,7 +1,7 @@ # coding: utf-8 from datetime import datetime, timedelta -from flask import g, render_template, request, jsonify -from flask.ext.sqlalchemy import SQLAlchemy +from flask import g, render_template, request, jsonify, make_response +from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import relationship from flask_oauthlib.provider import OAuth2Provider from flask_oauthlib.contrib.oauth2 import bind_sqlalchemy @@ -21,7 +21,7 @@ def check_password(self, password): class Client(db.Model): - #id = db.Column(db.Integer, primary_key=True) + # id = db.Column(db.Integer, primary_key=True) # human readable name name = db.Column(db.String(40)) client_id = db.Column(db.String(40), primary_key=True) @@ -29,7 +29,7 @@ class Client(db.Model): nullable=False) client_type = db.Column(db.String(20), default='public') _redirect_uris = db.Column(db.Text) - default_scope = db.Column(db.Text) + default_scope = db.Column(db.Text, default='email address') @property def user(self): @@ -51,6 +51,11 @@ def default_scopes(self): return self.default_scope.split() return [] + @property + def allowed_grant_types(self): + return ['authorization_code', 'password', 'client_credentials', + 'refresh_token'] + class Grant(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -100,8 +105,10 @@ class Token(db.Model): scope = db.Column(db.Text) def __init__(self, **kwargs): - expires_in = kwargs.pop('expires_in') - self.expires = datetime.utcnow() + timedelta(seconds=expires_in) + expires_in = kwargs.pop('expires_in', None) + if expires_in is not None: + self.expires = datetime.utcnow() + timedelta(seconds=expires_in) + for k, v in kwargs.items(): setattr(self, k, v) @@ -111,6 +118,11 @@ def scopes(self): return self.scope.split() return [] + def delete(self): + db.session.delete(self) + db.session.commit() + return self + def current_user(): return g.user @@ -212,17 +224,37 @@ def prepare_app(app): user = User(username='admin') + temp_grant = Grant( + user_id=1, client_id='confidential', + code='12345', scope='email', + expires=datetime.utcnow() + timedelta(seconds=100) + ) + + access_token = Token( + user_id=1, client_id='dev', access_token='expired', expires_in=0 + ) + + access_token2 = Token( + user_id=1, client_id='dev', access_token='never_expire' + ) + try: db.session.add(client1) db.session.add(client2) db.session.add(user) + db.session.add(temp_grant) + db.session.add(access_token) + db.session.add(access_token2) db.session.commit() except: db.session.rollback() return app -def create_server(app, oauth): +def create_server(app, oauth=None): + if not oauth: + oauth = default_provider(app) + app = prepare_app(app) @app.before_request @@ -242,34 +274,53 @@ def authorize(*args, **kwargs): # render a page for user to confirm the authorization return render_template('confirm.html') + if request.method == 'HEAD': + # if HEAD is supported properly, request parameters like + # client_id should be validated the same way as for 'GET' + response = make_response('', 200) + response.headers['X-Client-ID'] = kwargs.get('client_id') + return response + confirm = request.form.get('confirm', 'no') return confirm == 'yes' - @app.route('/oauth/token') + @app.route('/oauth/token', methods=['POST', 'GET']) @oauth.token_handler def access_token(): return {} + @app.route('/oauth/revoke', methods=['POST']) + @oauth.revoke_handler + def revoke_token(): + pass + @app.route('/api/email') @oauth.require_oauth('email') - def email_api(oauth): + def email_api(): + oauth = request.oauth return jsonify(email='me@oauth.net', username=oauth.user.username) @app.route('/api/client') @oauth.require_oauth() - def client_api(oauth): + def client_api(): + oauth = request.oauth return jsonify(client=oauth.client.name) @app.route('/api/address/') @oauth.require_oauth('address') - def address_api(oauth, city): + def address_api(city): + oauth = request.oauth return jsonify(address=city, username=oauth.user.username) @app.route('/api/method', methods=['GET', 'POST', 'PUT', 'DELETE']) @oauth.require_oauth() - def method_api(oauth): + def method_api(): return jsonify(method=request.method) + @oauth.invalid_response + def require_oauth_invalid(req): + return jsonify(message=req.error_message), 401 + return app diff --git a/tests/oauth2/test_oauth2.py b/tests/oauth2/test_oauth2.py index e4449a89..c9c17627 100644 --- a/tests/oauth2/test_oauth2.py +++ b/tests/oauth2/test_oauth2.py @@ -1,7 +1,6 @@ # coding: utf-8 import json -import base64 from flask import Flask from mock import MagicMock from .server import ( @@ -10,10 +9,10 @@ cache_provider, sqlalchemy_provider, default_provider, + Token ) from .client import create_client -from .._base import BaseSuite, clean_url -from .._base import to_bytes as b +from .._base import BaseSuite, clean_url, to_base64 from .._base import to_unicode as u @@ -40,6 +39,7 @@ def setup_app(self, app): client.http_request = MagicMock( side_effect=self.patch_request(app) ) + self.oauth_client = client return app @@ -49,11 +49,7 @@ def setup_app(self, app): ) -def _base64(text): - return u(base64.b64encode(b(text))) - - -auth_code = _base64('confidential:confidential') +auth_code = to_base64('confidential:confidential') class TestWebAuth(OAuthSuite): @@ -67,7 +63,7 @@ def test_login(self): def test_oauth_authorize_invalid_url(http://github.com/self): rv = self.client.get('/oauth/authorize') - assert 'invalid_client_id' in rv.location + assert 'Missing+client_id+parameter.' in rv.location def test_oauth_authorize_valid_url(http://github.com/self): rv = self.client.get(authorize_url) @@ -85,12 +81,25 @@ def test_oauth_authorize_valid_url(http://github.com/self): assert 'code=' in rv.location assert 'state' not in rv.location - # test state + # test state on access denied + # According to RFC 6749, state should be preserved on error response if it's present in the client request. + # Reference: https://tools.ietf.org/html/rfc6749#section-4.1.2 + rv = self.client.post(authorize_url + '&state=foo', data=dict( + confirm='no' + )) + assert 'error=access_denied' in rv.location + assert 'state=foo' in rv.location + + # test state on success rv = self.client.post(authorize_url + '&state=foo', data=dict( confirm='yes' )) assert 'code=' in rv.location - assert 'state' in rv.location + assert 'state=foo' in rv.location + + def test_http_head_oauth_authorize_valid_url(http://github.com/self): + rv = self.client.head(authorize_url) + assert rv.headers['X-Client-ID'] == 'dev' def test_get_access_token(self): rv = self.client.post(authorize_url, data={'confirm': 'yes'}) @@ -106,7 +115,8 @@ def test_full_flow(self): assert b'username' in rv.data rv = self.client.get('/address') - assert rv.status_code == 403 + assert rv.status_code == 401 + assert b'message' in rv.data rv = self.client.get('/method/post') assert b'POST' in rv.data @@ -117,31 +127,56 @@ def test_full_flow(self): rv = self.client.get('/method/delete') assert b'DELETE' in rv.data + def test_no_bear_token(self): + @self.oauth_client.tokengetter + def get_oauth_token(): + return 'foo', '' + + rv = self.client.get('/method/put') + assert b'token not found' in rv.data + + def test_expires_bear_token(self): + @self.oauth_client.tokengetter + def get_oauth_token(): + return 'expired', '' + + rv = self.client.get('/method/put') + assert b'token is expired' in rv.data + + def test_never_expiring_bear_token(self): + @self.oauth_client.tokengetter + def get_oauth_token(): + return 'never_expire', '' + + rv = self.client.get('/method/put') + assert rv.status_code == 200 + def test_get_client(self): rv = self.client.post(authorize_url, data={'confirm': 'yes'}) rv = self.client.get(clean_url(http://github.com/rv.location)) rv = self.client.get("/client") assert b'dev' in rv.data - def test_invalid_client_id(self): + def test_invalid_response_type(self): authorize_url = ( - '/oauth/authorize?response_type=code&client_id=confidential' + '/oauth/authorize?response_type=invalid&client_id=dev' '&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauthorized' '&scope=email' ) rv = self.client.post(authorize_url, data={'confirm': 'yes'}) rv = self.client.get(clean_url(http://github.com/rv.location)) - assert b'Invalid' in rv.data + assert b'error' in rv.data - def test_invalid_response_type(self): + def test_invalid_scope(self): authorize_url = ( - '/oauth/authorize?response_type=invalid&client_id=dev' + '/oauth/authorize?response_type=code&client_id=dev' '&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauthorized' - '&scope=email' + '&scope=invalid' ) - rv = self.client.post(authorize_url, data={'confirm': 'yes'}) + rv = self.client.get(authorize_url) rv = self.client.get(clean_url(http://github.com/rv.location)) assert b'error' in rv.data + assert b'invalid_scope' in rv.data class TestWebAuthCached(TestWebAuth): @@ -156,74 +191,112 @@ def create_oauth_provider(self, app): return sqlalchemy_provider(app) -class TestPasswordAuth(OAuthSuite): +class TestRefreshToken(OAuthSuite): def create_oauth_provider(self, app): return default_provider(app) - def test_get_access_token(self): - url = ('/oauth/token?grant_type=password&state=foo' + def test_refresh_token_in_password_grant(self): + url = ('/oauth/token?grant_type=password' '&scope=email+address&username=admin&password=admin') rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, - }, data={'confirm': 'yes'}) + }) assert b'access_token' in rv.data - assert b'state' in rv.data + data = json.loads(u(rv.data)) - def test_invalid_user_credentials(self): - url = ('/oauth/token?grant_type=password&state=foo' - '&scope=email+address&username=fake&password=admin') + args = (data.get('scope').replace(' ', '+'), + data.get('refresh_token')) + url = ('/oauth/token?grant_type=refresh_token' + '&scope=%s&refresh_token=%s') + url = url % args rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, - }, data={'confirm': 'yes'}) + }) + assert b'access_token' in rv.data + + def test_refresh_token_in_authorization_code(self): + rv = self.client.post(authorize_url, data={'confirm': 'yes'}) + rv = self.client.get(clean_url(http://github.com/rv.location)) + data = json.loads(u(rv.data)) - assert b'Invalid credentials given' in rv.data + args = (data.get('scope').replace(' ', '+'), + data.get('refresh_token'), 'dev', 'dev') + url = ('/oauth/token?grant_type=refresh_token' + '&scope=%s&refresh_token=%s' + '&client_id=%s&client_secret=%s') + url = url % args + rv = self.client.get(url) + assert b'access_token' in rv.data -class TestPasswordAuthCached(TestPasswordAuth): +class TestRefreshTokenCached(TestRefreshToken): def create_oauth_provider(self, app): return cache_provider(app) -class TestPasswordAuthSQLAlchemy(TestPasswordAuth): +class TestRefreshTokenSQLAlchemy(TestRefreshToken): def create_oauth_provider(self, app): return sqlalchemy_provider(app) -class TestRefreshToken(OAuthSuite): +class TestRevokeToken(OAuthSuite): def create_oauth_provider(self, app): return default_provider(app) - def test_refresh_token_in_password_grant(self): + def get_token(self): url = ('/oauth/token?grant_type=password' '&scope=email+address&username=admin&password=admin') rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, }) - assert b'access_token' in rv.data - data = json.loads(u(rv.data)) + assert b'_token' in rv.data + return json.loads(u(rv.data)) + + def test_revoke_token(self): + data = self.get_token() + tok = Token.query.filter_by( + refresh_token=data['refresh_token']).first() + assert tok.refresh_token == data['refresh_token'] + + revoke_url = '/oauth/revoke' + args = {'token': data['refresh_token']} + self.client.post(revoke_url, data=args, headers={ + 'Authorization': 'Basic %s' % auth_code, + }) - args = (data.get('scope').replace(' ', '+'), - data.get('refresh_token')) - url = ('/oauth/token?grant_type=refresh_token' - '&scope=%s&refresh_token=%s&username=admin') - url = url % args - rv = self.client.get(url, headers={ + tok = Token.query.filter_by( + refresh_token=data['refresh_token']).first() + assert tok is None + + def test_revoke_token_with_hint(self): + data = self.get_token() + tok = Token.query.filter_by( + access_token=data['access_token']).first() + assert tok.access_token == data['access_token'] + + revoke_url = '/oauth/revoke' + args = {'token': data['access_token'], + 'token_type_hint': 'access_token'} + self.client.post(revoke_url, data=args, headers={ 'Authorization': 'Basic %s' % auth_code, }) - assert b'access_token' in rv.data + tok = Token.query.filter_by( + access_token=data['access_token']).first() + assert tok is None -class TestRefreshTokenCached(TestRefreshToken): + +class TestRevokeTokenCached(TestRefreshToken): def create_oauth_provider(self, app): return cache_provider(app) -class TestRefreshTokenSQLAlchemy(TestRefreshToken): +class TestRevokeTokenSQLAlchemy(TestRefreshToken): def create_oauth_provider(self, app): return sqlalchemy_provider(app) @@ -236,36 +309,36 @@ def create_oauth_provider(self, app): def test_get_access_token(self): url = ('/oauth/token?grant_type=client_credentials' - '&scope=email+address&username=admin&password=admin') + '&scope=email+address') rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, - }, data={'confirm': 'yes'}) + }) assert b'access_token' in rv.data def test_invalid_auth_header(self): url = ('/oauth/token?grant_type=client_credentials' - '&scope=email+address&username=admin&password=admin') + '&scope=email+address') rv = self.client.get(url, headers={ 'Authorization': 'Basic foobar' - }, data={'confirm': 'yes'}) + }) assert b'invalid_client' in rv.data def test_no_client(self): - auth_code = _base64('none:confidential') + auth_code = to_base64('none:confidential') url = ('/oauth/token?grant_type=client_credentials' - '&scope=email+address&username=admin&password=admin') + '&scope=email+address') rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, - }, data={'confirm': 'yes'}) + }) assert b'invalid_client' in rv.data def test_wrong_secret_client(self): - auth_code = _base64('confidential:wrong') + auth_code = to_base64('confidential:wrong') url = ('/oauth/token?grant_type=client_credentials' - '&scope=email+address&username=admin&password=admin') + '&scope=email+address') rv = self.client.get(url, headers={ 'Authorization': 'Basic %s' % auth_code, - }, data={'confirm': 'yes'}) + }) assert b'invalid_client' in rv.data @@ -285,7 +358,7 @@ class TestTokenGenerator(OAuthSuite): def create_oauth_provider(self, app): - def generator(request, refresh_token=False): + def generator(request): return 'foobar' app.config['OAUTH2_PROVIDER_TOKEN_GENERATOR'] = generator @@ -297,3 +370,55 @@ def test_get_access_token(self): data = json.loads(u(rv.data)) assert data['access_token'] == 'foobar' assert data['refresh_token'] == 'foobar' + + +class TestRefreshTokenGenerator(OAuthSuite): + + def create_oauth_provider(self, app): + + def at_generator(request): + return 'foobar' + + def rt_generator(request): + return 'abracadabra' + + app.config['OAUTH2_PROVIDER_TOKEN_GENERATOR'] = at_generator + app.config['OAUTH2_PROVIDER_REFRESH_TOKEN_GENERATOR'] = rt_generator + return default_provider(app) + + def test_get_access_token(self): + rv = self.client.post(authorize_url, data={'confirm': 'yes'}) + rv = self.client.get(clean_url(http://github.com/rv.location)) + data = json.loads(u(rv.data)) + assert data['access_token'] == 'foobar' + assert data['refresh_token'] == 'abracadabra' + + +class TestConfidentialClient(OAuthSuite): + + def create_oauth_provider(self, app): + return default_provider(app) + + def test_get_access_token(self): + url = ('/oauth/token?grant_type=authorization_code&code=12345' + '&scope=email') + rv = self.client.get(url, headers={ + 'Authorization': 'Basic %s' % auth_code + }) + assert b'access_token' in rv.data + + def test_invalid_grant(self): + url = ('/oauth/token?grant_type=authorization_code&code=54321' + '&scope=email') + rv = self.client.get(url, headers={ + 'Authorization': 'Basic %s' % auth_code + }) + assert b'invalid_grant' in rv.data + + def test_invalid_client(self): + url = ('/oauth/token?grant_type=authorization_code&code=12345' + '&scope=email') + rv = self.client.get(url, headers={ + 'Authorization': 'Basic %s' % ('foo') + }) + assert b'invalid_client' in rv.data diff --git a/tests/test_client.py b/tests/test_client.py index 6dc021e1..81d64893 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,6 +3,7 @@ from flask_oauthlib.client import encode_request_data from flask_oauthlib.client import OAuthRemoteApp, OAuth from flask_oauthlib.client import parse_response +from oauthlib.common import PY3 try: import urllib2 as http @@ -12,7 +13,6 @@ http_urlopen = 'urllib.request.urlopen' from mock import patch -from .oauth2.client import create_client class Response(object): @@ -50,7 +50,18 @@ def test_encode_request_data(): def test_app(): app = Flask(__name__) - create_client(app) + oauth = OAuth(app) + remote = oauth.remote_app( + 'dev', + consumer_key='dev', + consumer_secret='dev', + request_token_params={'scope': 'email'}, + base_url='http://127.0.0.1:5000/api/', + request_token_url=None, + access_token_method='POST', + access_token_url='http://127.0.0.1:5000/oauth/token', + authorize_url='http://127.0.0.1:5000/oauth/authorize' + ) client = app.extensions['oauthlib.client'] assert client.dev.name == 'dev' @@ -68,7 +79,7 @@ def test_parse_xml(): @raises(AttributeError) def test_raise_app(): app = Flask(__name__) - app = create_client(app) + oauth = OAuth(app) client = app.extensions['oauthlib.client'] assert client.demo.name == 'dev' @@ -160,7 +171,46 @@ def test_raise_http_request(self, urlopen): 'http://example.com/', 404, 'Not Found', None, None ) error.read = lambda: b'o' + + class _Fake(object): + def close(self): + return 0 + + class _Faker(object): + _closer = _Fake() + + error.file = _Faker() + urlopen.side_effect = error resp, content = OAuthRemoteApp.http_request('http://example.com/') assert resp.code == 404 assert b'o' in content + + def test_token_types(self): + oauth = OAuth() + remote = oauth.remote_app('remote', + consumer_key='remote key', + consumer_secret='remote secret') + + client_token = {'access_token': 'access token'} + + if not PY3: + unicode_token = u'access token' + client = remote.make_client(token=unicode_token) + assert client.token == client_token + + str_token = 'access token' + client = remote.make_client(token=str_token) + assert client.token == client_token + + list_token = ['access token'] + client = remote.make_client(token=list_token) + assert client.token == client_token + + tuple_token = ('access token',) + client = remote.make_client(token=tuple_token) + assert client.token == client_token + + dict_token = {'access_token': 'access token'} + client = remote.make_client(token=dict_token) + assert client.token == client_token diff --git a/tests/test_contrib/__init__.py b/tests/test_contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_contrib/test_apps.py b/tests/test_contrib/test_apps.py new file mode 100644 index 00000000..3e57597c --- /dev/null +++ b/tests/test_contrib/test_apps.py @@ -0,0 +1,51 @@ +import unittest + +from flask import Flask +from flask_oauthlib.client import OAuth +from flask_oauthlib.contrib.apps import douban, linkedin +from nose.tools import assert_raises + + +class RemoteAppFactorySuite(unittest.TestCase): + + def setUp(self): + self.app = Flask(__name__) + self.oauth = OAuth(self.app) + + def test_douban(self): + assert 'douban.com' in douban.__doc__ + assert ':param scope:' in douban.__doc__ + + c1 = douban.create(self.oauth) + assert 'api.douban.com/v2' in c1.base_url + assert c1.request_token_params.get('scope') == 'douban_basic_common' + + assert_raises(KeyError, lambda: c1.consumer_key) + assert_raises(KeyError, lambda: c1.consumer_secret) + + self.app.config['DOUBAN_CONSUMER_KEY'] = 'douban key' + self.app.config['DOUBAN_CONSUMER_SECRET'] = 'douban secret' + assert c1.consumer_key == 'douban key' + assert c1.consumer_secret == 'douban secret' + + c2 = douban.register_to(self.oauth, 'doudou', scope=['a', 'b']) + assert c2.request_token_params.get('scope') == 'a,b' + + assert_raises(KeyError, lambda: c2.consumer_key) + self.app.config['DOUDOU_CONSUMER_KEY'] = 'douban2 key' + assert c2.consumer_key == 'douban2 key' + + def test_linkedin(self): + c1 = linkedin.create(self.oauth) + assert c1.name == 'linkedin' + assert c1.request_token_params == { + 'state': 'RandomString', + 'scope': 'r_basicprofile', + } + + c2 = linkedin.register_to(self.oauth, name='l2', scope=['c', 'd']) + assert c2.name == 'l2' + assert c2.request_token_params == { + 'state': 'RandomString', + 'scope': 'c,d', + }, c2.request_token_params diff --git a/tests/test_oauth2/__init__.py b/tests/test_oauth2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_oauth2/base.py b/tests/test_oauth2/base.py new file mode 100644 index 00000000..2fee2d22 --- /dev/null +++ b/tests/test_oauth2/base.py @@ -0,0 +1,319 @@ +# coding: utf-8 + +import os +import unittest +from datetime import datetime, timedelta +from flask import Flask +from flask import g, render_template, request, jsonify, make_response +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import relationship +from flask_oauthlib.provider import OAuth2Provider +from flask_oauthlib.contrib.oauth2 import bind_sqlalchemy +from flask_oauthlib.contrib.oauth2 import bind_cache_grant + +os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = 'true' + +db = SQLAlchemy() + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(40), unique=True, index=True, + nullable=False) + + def check_password(self, password): + return password != 'wrong' + + +class Client(db.Model): + # id = db.Column(db.Integer, primary_key=True) + # human readable name + name = db.Column(db.String(40)) + client_id = db.Column(db.String(40), primary_key=True) + client_secret = db.Column(db.String(55), unique=True, index=True, + nullable=False) + _redirect_uris = db.Column(db.Text) + default_scope = db.Column(db.Text, default='email address') + disallow_grant_type = db.Column(db.String(20)) + is_confidential = db.Column(db.Boolean, default=True) + + @property + def user(self): + return User.query.get(1) + + @property + def redirect_uris(self): + if self._redirect_uris: + return self._redirect_uris.split() + return [] + + @property + def default_redirect_uri(self): + return self.redirect_uris[0] + + @property + def default_scopes(self): + if self.default_scope: + return self.default_scope.split() + return [] + + @property + def allowed_grant_types(self): + types = [ + 'authorization_code', 'password', + 'client_credentials', 'refresh_token', + ] + if self.disallow_grant_type: + types.remove(self.disallow_grant_type) + return types + + +class Grant(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey('user.id', ondelete='CASCADE') + ) + user = relationship('User') + + client_id = db.Column( + db.String(40), db.ForeignKey('client.client_id', ondelete='CASCADE'), + nullable=False, + ) + client = relationship('Client') + code = db.Column(db.String(255), index=True, nullable=False) + + redirect_uri = db.Column(db.String(255)) + scope = db.Column(db.Text) + expires = db.Column(db.DateTime) + + def delete(self): + db.session.delete(self) + db.session.commit() + return self + + @property + def scopes(self): + if self.scope: + return self.scope.split() + return None + + +class Token(db.Model): + id = db.Column(db.Integer, primary_key=True) + client_id = db.Column( + db.String(40), db.ForeignKey('client.client_id', ondelete='CASCADE'), + nullable=False, + ) + user_id = db.Column( + db.Integer, db.ForeignKey('user.id', ondelete='CASCADE') + ) + user = relationship('User') + client = relationship('Client') + token_type = db.Column(db.String(40)) + access_token = db.Column(db.String(255)) + refresh_token = db.Column(db.String(255)) + expires = db.Column(db.DateTime) + scope = db.Column(db.Text) + + def __init__(self, **kwargs): + expires_in = kwargs.pop('expires_in') + self.expires = datetime.utcnow() + timedelta(seconds=expires_in) + for k, v in kwargs.items(): + setattr(self, k, v) + + @property + def scopes(self): + if self.scope: + return self.scope.split() + return [] + + def delete(self): + db.session.delete(self) + db.session.commit() + return self + + +def current_user(): + return g.user + + +def cache_provider(app): + oauth = OAuth2Provider(app) + + bind_sqlalchemy(oauth, db.session, user=User, + token=Token, client=Client, current_user=current_user) + + app.config.update({'OAUTH2_CACHE_TYPE': 'simple'}) + bind_cache_grant(app, oauth, current_user) + return oauth + + +def sqlalchemy_provider(app): + oauth = OAuth2Provider(app) + + bind_sqlalchemy(oauth, db.session, user=User, token=Token, + client=Client, grant=Grant, current_user=current_user) + + return oauth + + +def default_provider(app): + oauth = OAuth2Provider(app) + + @oauth.clientgetter + def get_client(client_id): + return Client.query.filter_by(client_id=client_id).first() + + @oauth.grantgetter + def get_grant(client_id, code): + return Grant.query.filter_by(client_id=client_id, code=code).first() + + @oauth.tokengetter + def get_token(access_token=None, refresh_token=None): + if access_token: + return Token.query.filter_by(access_token=access_token).first() + if refresh_token: + return Token.query.filter_by(refresh_token=refresh_token).first() + return None + + @oauth.grantsetter + def set_grant(client_id, code, request, *args, **kwargs): + expires = datetime.utcnow() + timedelta(seconds=100) + grant = Grant( + client_id=client_id, + code=code['code'], + redirect_uri=request.redirect_uri, + scope=' '.join(request.scopes), + user_id=g.user.id, + expires=expires, + ) + db.session.add(grant) + db.session.commit() + + @oauth.tokensetter + def set_token(token, request, *args, **kwargs): + # In real project, a token is unique bound to user and client. + # Which means, you don't need to create a token every time. + tok = Token(**token) + if request.response_type == 'token': + tok.user_id = g.user.id + else: + tok.user_id = request.user.id + tok.client_id = request.client.client_id + db.session.add(tok) + db.session.commit() + + @oauth.usergetter + def get_user(username, password, *args, **kwargs): + # This is optional, if you don't need password credential + # there is no need to implement this method + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + return user + return None + + return oauth + + +def create_server(app, oauth=None): + if not oauth: + oauth = default_provider(app) + + @app.before_request + def load_current_user(): + user = User.query.get(1) + g.user = user + + @app.route('/home') + def home(): + return render_template('home.html') + + @app.route('/oauth/authorize', methods=['GET', 'POST']) + @oauth.authorize_handler + def authorize(*args, **kwargs): + # NOTICE: for real project, you need to require login + if request.method == 'GET': + # render a page for user to confirm the authorization + return 'confirm page' + + if request.method == 'HEAD': + # if HEAD is supported properly, request parameters like + # client_id should be validated the same way as for 'GET' + response = make_response('', 200) + response.headers['X-Client-ID'] = kwargs.get('client_id') + return response + + confirm = request.form.get('confirm', 'no') + return confirm == 'yes' + + @app.route('/oauth/token', methods=['POST', 'GET']) + @oauth.token_handler + def access_token(): + return {} + + @app.route('/oauth/revoke', methods=['POST']) + @oauth.revoke_handler + def revoke_token(): + return {} + + @app.route('/api/email') + @oauth.require_oauth('email') + def email_api(): + oauth = request.oauth + return jsonify(email='me@oauth.net', username=oauth.user.username) + + @app.route('/api/client') + @oauth.require_oauth() + def client_api(): + oauth = request.oauth + return jsonify(client=oauth.client.name) + + @app.route('/api/address/') + @oauth.require_oauth('address') + def address_api(city): + oauth = request.oauth + return jsonify(address=city, username=oauth.user.username) + + @app.route('/api/method', methods=['GET', 'POST', 'PUT', 'DELETE']) + @oauth.require_oauth() + def method_api(): + return jsonify(method=request.method) + + @oauth.invalid_response + def require_oauth_invalid(req): + return jsonify(message=req.error_message), 401 + + return app + + +class TestCase(unittest.TestCase): + def setUp(self): + app = self.create_app() + + app.testing = True + self._ctx = app.app_context() + self._ctx.push() + + db.init_app(app) + db.create_all() + + self.app = app + self.client = app.test_client() + self.prepare_data() + + def tearDown(self): + db.drop_all() + self._ctx.pop() + + def prepare_data(self): + return True + + def create_app(self): + app = Flask(__name__) + app.debug = True + app.secret_key = 'testing' + app.config.update({ + 'SQLALCHEMY_DATABASE_URI': 'sqlite://', + 'SQLALCHEMY_TRACK_MODIFICATIONS': False + }) + return app diff --git a/tests/test_oauth2/test_client_credential.py b/tests/test_oauth2/test_client_credential.py new file mode 100644 index 00000000..317a50be --- /dev/null +++ b/tests/test_oauth2/test_client_credential.py @@ -0,0 +1,55 @@ +# coding: utf-8 + +from .._base import to_base64 +from .base import TestCase +from .base import create_server, sqlalchemy_provider, cache_provider +from .base import db, Client, User + + +class TestDefaultProvider(TestCase): + def create_server(self): + create_server(self.app) + + def prepare_data(self): + self.create_server() + + oauth_client = Client( + name='ios', client_id='client', client_secret='secret', + _redirect_uris='http://localhost/authorized', + ) + + db.session.add(User(username='foo')) + db.session.add(oauth_client) + db.session.commit() + + self.oauth_client = oauth_client + + def test_get_token(self): + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'client_credentials', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert b'access_token' in rv.data + + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'client_credentials' + }, headers={ + 'authorization': 'Basic ' + to_base64( + '%s:%s' % ( + self.oauth_client.client_id, + self.oauth_client.client_secret + ) + ) + }) + assert b'access_token' in rv.data + + +class TestSQLAlchemyProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, sqlalchemy_provider(self.app)) + + +class TestCacheProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, cache_provider(self.app)) diff --git a/tests/test_oauth2/test_code.py b/tests/test_oauth2/test_code.py new file mode 100644 index 00000000..59946a7c --- /dev/null +++ b/tests/test_oauth2/test_code.py @@ -0,0 +1,183 @@ +# coding: utf-8 + +from datetime import datetime, timedelta +from .._base import to_base64 +from .base import TestCase, default_provider +from .base import create_server, sqlalchemy_provider, cache_provider +from .base import db, Client, User, Grant + + +class TestDefaultProvider(TestCase): + def create_server(self): + create_server(self.app) + + def prepare_data(self): + self.create_server() + + oauth_client = Client( + name='ios', client_id='code-client', client_secret='code-secret', + _redirect_uris='http://localhost/authorized', + ) + + db.session.add(User(username='foo')) + db.session.add(oauth_client) + db.session.commit() + + self.oauth_client = oauth_client + self.authorize_url = ( + '/oauth/authorize?response_type=code&client_id=%s' + ) % oauth_client.client_id + + def test_get_authorize(self): + rv = self.client.get('/oauth/authorize') + assert 'client_id' in rv.location + + rv = self.client.get('/oauth/authorize?client_id=no') + assert 'client_id' in rv.location + + url = '/oauth/authorize?client_id=%s' % self.oauth_client.client_id + rv = self.client.get(url) + assert 'error' in rv.location + + rv = self.client.get(self.authorize_url) + assert b'confirm' in rv.data + + def test_post_authorize(self): + url = self.authorize_url + '&scope=foo' + rv = self.client.post(url, data={'confirm': 'yes'}) + assert 'invalid_scope' in rv.location + + url = self.authorize_url + '&scope=email' + rv = self.client.post(url, data={'confirm': 'yes'}) + assert 'code' in rv.location + + url = self.authorize_url + '&scope=' + rv = self.client.post(url, data={'confirm': 'yes'}) + assert 'error=Scopes+must+be+set' in rv.location + + def test_invalid_token(self): + rv = self.client.get('/oauth/token') + assert b'unsupported_grant_type' in rv.data + + rv = self.client.get('/oauth/token?grant_type=authorization_code') + assert b'error' in rv.data + assert b'code' in rv.data + + url = ( + '/oauth/token?grant_type=authorization_code' + '&code=nothing&client_id=%s' + ) % self.oauth_client.client_id + rv = self.client.get(url) + assert b'invalid_client' in rv.data + + url += '&client_secret=' + self.oauth_client.client_secret + rv = self.client.get(url) + assert b'invalid_client' not in rv.data + assert rv.status_code == 401 + + def test_invalid_redirect_uri(self): + authorize_url = ( + '/oauth/authorize?response_type=code&client_id=code-client' + '&redirect_uri=http://localhost:8000/authorized' + '&scope=invalid' + ) + rv = self.client.get(authorize_url) + assert 'error=' in rv.location + assert 'Mismatching+redirect+URI' in rv.location + + def test_get_token(self): + expires = datetime.utcnow() + timedelta(seconds=100) + grant = Grant( + user_id=1, + client_id=self.oauth_client.client_id, + scope='email', + redirect_uri='http://localhost/authorized', + code='test-get-token', + expires=expires, + ) + db.session.add(grant) + db.session.commit() + + url = '/oauth/token?grant_type=authorization_code&code=test-get-token' + rv = self.client.get( + url + '&client_id=%s' % (self.oauth_client.client_id) + ) + assert b'invalid_client' in rv.data + + rv = self.client.get( + url + '&client_id=%s&client_secret=%s' % ( + self.oauth_client.client_id, + self.oauth_client.client_secret + ) + ) + assert b'access_token' in rv.data + + grant = Grant( + user_id=1, + client_id=self.oauth_client.client_id, + scope='email', + redirect_uri='http://localhost/authorized', + code='test-get-token', + expires=expires, + ) + db.session.add(grant) + db.session.commit() + + rv = self.client.get(url, headers={ + 'authorization': 'Basic ' + to_base64( + '%s:%s' % ( + self.oauth_client.client_id, + self.oauth_client.client_secret + ) + ) + }) + assert b'access_token' in rv.data + + +class TestSQLAlchemyProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, sqlalchemy_provider(self.app)) + + +class TestCacheProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, cache_provider(self.app)) + + def test_get_token(self): + url = self.authorize_url + '&scope=email' + rv = self.client.post(url, data={'confirm': 'yes'}) + assert 'code' in rv.location + code = rv.location.split('code=')[1] + + url = ( + '/oauth/token?grant_type=authorization_code' + '&code=%s&client_id=%s' + ) % (code, self.oauth_client.client_id) + rv = self.client.get(url) + assert b'invalid_client' in rv.data + + url += '&client_secret=' + self.oauth_client.client_secret + rv = self.client.get(url) + assert b'access_token' in rv.data + + +class TestProviderWithExceptionHandler(TestCase): + + def prepare_data(self): + oauth = default_provider(self.app) + + @oauth.exception_handler + def custom_exception_handler(error, *args): + raise error + + @self.app.errorhandler(Exception) + def all_exception_handler(*args): + return "Testing server error", 500 + + create_server(self.app, oauth=oauth) + + def test_exception_handler(self): + rv = self.client.get('/oauth/authorize') + + assert rv.status_code == 500 + assert rv.data.decode("utf-8") == "Testing server error" diff --git a/tests/test_oauth2/test_implicit.py b/tests/test_oauth2/test_implicit.py new file mode 100644 index 00000000..f464d862 --- /dev/null +++ b/tests/test_oauth2/test_implicit.py @@ -0,0 +1,44 @@ +# coding: utf-8 + +from .base import TestCase +from .base import create_server, sqlalchemy_provider, cache_provider +from .base import db, Client, User + + +class TestDefaultProvider(TestCase): + def create_server(self): + create_server(self.app) + + def prepare_data(self): + self.create_server() + + oauth_client = Client( + name='ios', client_id='imp-client', client_secret='imp-secret', + _redirect_uris='http://localhost/authorized', + ) + + db.session.add(User(username='foo')) + db.session.add(oauth_client) + db.session.commit() + + self.oauth_client = oauth_client + + def test_implicit(self): + rv = self.client.post('/oauth/authorize', data={ + 'response_type': 'token', + 'confirm': 'yes', + 'scope': 'email', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert 'access_token' in rv.location + + +class TestSQLAlchemyProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, sqlalchemy_provider(self.app)) + + +class TestCacheProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, cache_provider(self.app)) diff --git a/tests/test_oauth2/test_password.py b/tests/test_oauth2/test_password.py new file mode 100644 index 00000000..1c26a8dd --- /dev/null +++ b/tests/test_oauth2/test_password.py @@ -0,0 +1,96 @@ +# coding: utf-8 + +from .base import TestCase +from .base import create_server, sqlalchemy_provider, cache_provider +from .base import db, Client, User + + +class TestDefaultProvider(TestCase): + def create_server(self): + create_server(self.app) + + def prepare_data(self): + self.create_server() + + oauth_client = Client( + name='ios', client_id='pass-client', client_secret='pass-secret', + _redirect_uris='http://localhost/authorized', + ) + + db.session.add(User(username='foo')) + db.session.add(oauth_client) + db.session.commit() + + self.oauth_client = oauth_client + + def test_invalid_username(self): + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'notfound', + 'password': 'right', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert b'error' in rv.data + + def test_invalid_password(self): + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'foo', + 'password': 'wrong', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert b'error' in rv.data + + def test_missing_client_secret(self): + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'foo', + 'password': 'wrong', + 'client_id': self.oauth_client.client_id, + }) + assert b'error' in rv.data + + def test_get_token(self): + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'foo', + 'password': 'right', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert b'access_token' in rv.data + + # in Authorization + auth = 'cGFzcy1jbGllbnQ6cGFzcy1zZWNyZXQ=' + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'foo', + 'password': 'right', + }, headers={'Authorization': 'Basic %s' % auth}) + assert b'access_token' in rv.data + + def test_disallow_grant_type(self): + self.oauth_client.disallow_grant_type = 'password' + db.session.add(self.oauth_client) + db.session.commit() + + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'password', + 'username': 'foo', + 'password': 'right', + 'client_id': self.oauth_client.client_id, + 'client_secret': self.oauth_client.client_secret, + }) + assert b'error' in rv.data + + +class TestSQLAlchemyProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, sqlalchemy_provider(self.app)) + + +class TestCacheProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, cache_provider(self.app)) diff --git a/tests/test_oauth2/test_refresh.py b/tests/test_oauth2/test_refresh.py new file mode 100644 index 00000000..785fcef1 --- /dev/null +++ b/tests/test_oauth2/test_refresh.py @@ -0,0 +1,109 @@ +# coding: utf-8 + +import json +from .._base import to_base64, to_unicode as u +from .base import TestCase +from .base import create_server, sqlalchemy_provider, cache_provider +from .base import db, Client, User, Token + + +class TestDefaultProvider(TestCase): + def create_server(self): + create_server(self.app) + + def prepare_data(self): + self.create_server() + + normal_client = Client( + name='normal_client', + client_id='normal_client', + client_secret='normal_secret', + is_confidential=False, + _redirect_uris='http://localhost/authorized', + ) + + confidential_client = Client( + name='confidential_client', + client_id='confidential_client', + client_secret='confidential_secret', + is_confidential=True, + _redirect_uris='http://localhost/authorized', + ) + + db.session.add(User(username='foo')) + db.session.add(normal_client) + db.session.add(confidential_client) + db.session.commit() + + self.normal_client = normal_client + self.confidential_client = confidential_client + + def test_normal_get_token(self): + user = User.query.first() + token = Token( + user_id=user.id, + client_id=self.normal_client.client_id, + access_token='foo', + refresh_token='bar', + expires_in=1000, + ) + db.session.add(token) + db.session.commit() + + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': token.refresh_token, + 'client_id': self.normal_client.client_id, + }) + assert b'access_token' in rv.data + + def test_confidential_get_token(self): + user = User.query.first() + token = Token( + user_id=user.id, + client_id=self.confidential_client.client_id, + access_token='foo', + refresh_token='bar', + expires_in=1000, + ) + db.session.add(token) + db.session.commit() + + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': token.refresh_token, + 'client_id': self.confidential_client.client_id, + }) + assert b'error' in rv.data + + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': token.refresh_token, + 'client_id': self.confidential_client.client_id, + 'client_secret': self.confidential_client.client_secret, + }) + assert b'access_token' in rv.data + + token.refresh_token = json.loads(u(rv.data))['refresh_token'] + rv = self.client.post('/oauth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': token.refresh_token, + }, headers={ + 'authorization': 'Basic ' + to_base64( + '%s:%s' % ( + self.confidential_client.client_id, + self.confidential_client.client_secret + ) + ) + }) + assert b'access_token' in rv.data + + +class TestSQLAlchemyProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, sqlalchemy_provider(self.app)) + + +class TestCacheProvider(TestDefaultProvider): + def create_server(self): + create_server(self.app, cache_provider(self.app)) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..c3cb8f5a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,44 @@ +import unittest +import wsgiref.util +from contextlib import contextmanager +import mock +import werkzeug.wrappers +from flask_oauthlib.utils import extract_params +from oauthlib.common import Request + + +@contextmanager +def set_flask_request(wsgi_environ): + """ + Test helper context manager that mocks the flask request global I didn't + need the whole request context just to test the functions in helpers and I + wanted to be able to set the raw WSGI environment + """ + environ = {} + environ.update(wsgi_environ) + wsgiref.util.setup_testing_defaults(environ) + r = werkzeug.wrappers.Request(environ) + + with mock.patch.dict(extract_params.__globals__, {'request': r}): + yield + + +class UtilsTestSuite(unittest.TestCase): + + def test_extract_params(self): + with set_flask_request({'QUERY_STRING': 'test=foo&foo=bar'}): + uri, http_method, body, headers = extract_params() + self.assertEquals(uri, 'http://127.0.0.1/?test=foo&foo=bar') + self.assertEquals(http_method, 'GET') + self.assertEquals(body, {}) + self.assertEquals(headers, {'Host': '127.0.0.1'}) + + def test_extract_params_with_urlencoded_json(self): + wsgi_environ = { + 'QUERY_STRING': 'state=%7B%22t%22%3A%22a%22%2C%22i%22%3A%22l%22%7D' + } + with set_flask_request(wsgi_environ): + uri, http_method, body, headers = extract_params() + # Request constructor will try to urldecode the querystring, make + # sure this doesn't fail. + Request(uri, http_method, body, headers) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..8c74b31d --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py27,py33,py34,py35,py36,pypy + +[testenv] +deps = + nose + -rrequirements.txt +commands = nosetests -s