├── doc ├── _static │ └── PLACEHOLDER ├── _templates │ └── PLACEHOLDER ├── index.rst ├── Makefile └── conf.py ├── mwoauth ├── tests │ ├── __init__.py │ └── test_functions.py ├── errors.py ├── defaults.py ├── about.py ├── __init__.py ├── tokens.py ├── handshaker.py ├── flask.py └── functions.py ├── MANIFEST.in ├── setup.cfg ├── requirements.txt ├── .travis.yml ├── tox.ini ├── .gitignore ├── setup.py ├── LICENSE ├── examples ├── functions.py ├── handshaker.py ├── request-oauthlib.py └── flask_server.py └── README.rst /doc/_static/PLACEHOLDER: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/_templates/PLACEHOLDER: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mwoauth/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst requirements.txt 2 | -------------------------------------------------------------------------------- /mwoauth/errors.py: -------------------------------------------------------------------------------- 1 | class OAuthException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /mwoauth/defaults.py: -------------------------------------------------------------------------------- 1 | USER_AGENT = "python-mwoauth default user agent" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=.tox,build,dist,doc,examples,*.egg,*.egg-info 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyJWT == 2.4.0 2 | requests == 2.21.0 3 | requests-oauthlib == 1.2.0 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.5" 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /mwoauth/about.py: -------------------------------------------------------------------------------- 1 | __name__ = "mwoauth" 2 | __version__ = "0.4.0" 3 | __author__ = "Aaron Halfaker / Filippo Valsorda" 4 | __author_email__ = "aaron.halfaker@gmail.com" 5 | __description__ = "A generic MediaWiki OAuth handshake helper." 6 | __license__ = "MIT" 7 | __url__ = "https://github.com/mediawiki-utilities/python-mwoauth" 8 | 9 | all = [__name__, __version__, __author__, __author_email__, __description__, 10 | __license__, __url__] 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, flake8, doc 3 | minversion = 1.6 4 | skipsdist = True 5 | 6 | [testenv] 7 | deps = -r{toxinidir}/requirements.txt 8 | flask 9 | commands=python setup.py test 10 | 11 | [testenv:py35] 12 | basepython = python3.5 13 | 14 | [testenv:doc] 15 | deps = -r{toxinidir}/requirements.txt 16 | flask 17 | sphinx 18 | commands = sphinx-build -b html doc/ doc/_build/html 19 | 20 | [testenv:flake8] 21 | commands = flake8 {posargs} 22 | deps = flake8 23 | 24 | [tox:travis] 25 | 3.5 = py35, flake8, doc 26 | -------------------------------------------------------------------------------- /mwoauth/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides a collection of utilities for easily working with MediaWiki's 2 | OAuth1.0a implementation.""" 3 | from .handshaker import Handshaker 4 | from .tokens import AccessToken, ConsumerToken, RequestToken 5 | from .functions import initiate, complete, identify 6 | from .errors import OAuthException 7 | from .about import (__name__, __version__, __author__, __author_email__, 8 | __description__, __license__, __url__) 9 | 10 | 11 | __all__ = [ 12 | ConsumerToken, 13 | RequestToken, 14 | AccessToken, 15 | initiate, 16 | complete, 17 | identify, 18 | Handshaker, 19 | OAuthException, 20 | __name__, __version__, __author__, __author_email__, 21 | __description__, __license__, __url__ 22 | ] 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temp files 2 | *~ 3 | 4 | # Secret files for demoing 5 | credentials.do_not_commit.json 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | bin/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | 46 | # Mr Developer 47 | .mr.developer.cfg 48 | .project 49 | .pydevproject 50 | 51 | # Rope 52 | .ropeproject 53 | 54 | # Django stuff: 55 | *.log 56 | *.pot 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | doc/_build/ 61 | -------------------------------------------------------------------------------- /mwoauth/tests/test_functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import pytest 3 | 4 | from ..errors import OAuthException 5 | from ..functions import process_request_token 6 | 7 | 8 | def test_process_request_token(): 9 | request_token = process_request_token( 10 | "oauth_token=iamatoken&oauth_token_secret=iamasecret") 11 | assert request_token.key == "iamatoken" 12 | assert request_token.secret == "iamasecret" 13 | 14 | 15 | def test_process_request_token_errors(): 16 | text = "Error: Произошла ошибка в протоколе OAuth: " + \ 17 | "Invalid consumer key" 18 | #content = bytes(text, "utf-8") 19 | with pytest.raises(OAuthException, match=text[len("Error: "):]): 20 | process_request_token(text) 21 | 22 | with pytest.raises(OAuthException, match="I am an error"): 23 | process_request_token("Error: I am an error") 24 | 25 | with pytest.raises(OAuthException, match=r"Expected x-www-form-.*"): 26 | process_request_token("TOTAL NONSENSE") 27 | 28 | with pytest.raises(OAuthException, match=r"MediaWiki response lacks.*"): 29 | process_request_token("foo=bar&baz=bum") 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def read(fname): 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | 9 | 10 | about_path = os.path.join(os.path.dirname(__file__), "mwoauth/about.py") 11 | exec(compile(open(about_path).read(), about_path, "exec")) 12 | 13 | 14 | setup( 15 | name=__name__, # noqa 16 | version=__version__, # noqa 17 | author=__author__, # noqa 18 | author_email=__author_email__, # noqa 19 | description=__description__, # noqa 20 | url=__url__, # noqa 21 | license=__license__, # noqa 22 | packages=find_packages(), 23 | long_description=read('README.rst'), 24 | install_requires=[ 25 | 'PyJWT>=1.0.1', 26 | 'oauthlib', 27 | 'requests', 28 | 'requests-oauthlib', 29 | ], 30 | extras_require={ 31 | 'flask': ['flask'], 32 | }, 33 | classifiers=[ 34 | "Development Status :: 4 - Beta", 35 | "Topic :: Security", 36 | "License :: OSI Approved :: MIT License", 37 | "Topic :: Software Development :: Libraries :: Python Modules" 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Aaron Halfaker / Filippo Valsorda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/functions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from mwoauth import ConsumerToken, complete, identify, initiate 5 | 6 | sys.path.insert(0, ".") 7 | 8 | try: 9 | creds_doc = json.load(open("credentials.do_not_commit.json")) 10 | consumer_key = creds_doc['consumer_key'] 11 | consumer_secret = creds_doc['consumer_secret'] 12 | except FileNotFoundError: 13 | print('Couldn\'t find "credentials.do_not_commit.json". ' + 14 | 'Please manually input credentials.') 15 | consumer_key = input('Consumer key: ') 16 | consumer_secret = input('Consumer secret: ') 17 | 18 | consumer_token = ConsumerToken(consumer_key, consumer_secret) 19 | 20 | mw_uri = "https://en.wikipedia.org/w/index.php" 21 | 22 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for user 23 | redirect, request_token = initiate(mw_uri, consumer_token) 24 | 25 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 26 | print("Point your browser to: %s" % redirect) # 27 | response_qs = input("Response query string: ") 28 | 29 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 30 | access_token = complete(mw_uri, consumer_token, request_token, response_qs) 31 | print(str(access_token)) 32 | 33 | # Step 4: Identify -- (optional) get identifying information about the user 34 | identity = identify(mw_uri, consumer_token, access_token) 35 | print("Identified as {username} (id={sub}).".format(**identity)) 36 | -------------------------------------------------------------------------------- /examples/handshaker.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from mwoauth import ConsumerToken, Handshaker 5 | 6 | try: 7 | creds_doc = json.load(open("credentials.do_not_commit.json")) 8 | consumer_key = creds_doc['consumer_key'] 9 | consumer_secret = creds_doc['consumer_secret'] 10 | except FileNotFoundError: 11 | print('Couldn\'t find "credentials.do_not_commit.json". ' + 12 | 'Please manually input credentials.') 13 | consumer_key = input('Consumer key: ') 14 | consumer_secret = input('Consumer secret: ') 15 | 16 | consumer_token = ConsumerToken(consumer_key, consumer_secret) 17 | 18 | # Construct handshaker with wiki URI and consumer 19 | handshaker = Handshaker("https://en.wikipedia.org/w/index.php", 20 | consumer_token) 21 | 22 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for user 23 | redirect, request_token = handshaker.initiate() 24 | 25 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 26 | print("Point your browser to: %s" % redirect) # 27 | response_qs = input("Response query string: ") 28 | 29 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 30 | access_token = handshaker.complete(request_token, response_qs) 31 | print(str(access_token)) 32 | 33 | # Step 4: Identify -- (optional) get identifying information about the user 34 | identity = handshaker.identify(access_token) 35 | print("Identified as {username} (id={sub}).".format(**identity)) 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | MediaWiki OAuth Library 2 | ======================= 3 | 4 | ``mwoauth`` is an open licensed (MIT) library designed to provide a simple means to performing an OAuth handshake with a MediaWiki installation with the `OAuth Extension `_ installed. 5 | 6 | **Compatible with python 3.x** 7 | 8 | **Install with pip:** ``pip install mwoauth`` 9 | 10 | **Documentation:** http://pythonhosted.org/mwoauth 11 | 12 | Usage 13 | ===== 14 | 15 | .. code-block:: python 16 | 17 | from mwoauth import ConsumerToken, Handshaker 18 | 19 | # Construct a "consumer" from the key/secret provided by MediaWiki 20 | import config 21 | consumer_token = ConsumerToken(config.consumer_key, config.consumer_secret) 22 | 23 | # Construct handshaker with wiki URI and consumer 24 | handshaker = Handshaker("https://en.wikipedia.org/w/index.php", 25 | consumer_token) 26 | 27 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for user 28 | redirect, request_token = handshaker.initiate() 29 | 30 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 31 | print("Point your browser to: %s" % redirect) # 32 | response_qs = input("Response query string: ") 33 | 34 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 35 | access_token = handshaker.complete(request_token, response_qs) 36 | print(str(access_token)) 37 | 38 | # Step 4: Identify -- (optional) get identifying information about the user 39 | identity = handshaker.identify(access_token) 40 | print("Identified as {username}.".format(**identity)) 41 | 42 | -------------------------------------------------------------------------------- /mwoauth/tokens.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of tokens (key/secret pairs) used to identify actors during and after 3 | an OAuth handshake. 4 | """ 5 | from collections import namedtuple 6 | 7 | ConsumerToken = namedtuple("ConsumerToken", ['key', 'secret']) 8 | """ 9 | Represents a consumer (you). This key/secrets pair is provided by MediaWiki 10 | when you register an OAuth consumer (see 11 | ``Special:OAuthConsumerRegistration``). Note that Extension:OAuth must be 12 | installed in order in order for ``Special:OAuthConsumerRegistration`` to 13 | appear. 14 | 15 | :Parameters: 16 | key : `str` 17 | A hex string identifying the user 18 | secret : `str` 19 | A hex string used to sign communications 20 | """ 21 | 22 | RequestToken = namedtuple("RequestToken", ['key', 'secret']) 23 | """ 24 | Represents a request for access during authorization. This key/secret pair 25 | is provided by MediaWiki via ``Special:OAuth/initiate``. 26 | Once the user authorize you, this token can be traded for an `AccessToken` 27 | via `complete()`. 28 | 29 | :Parameters: 30 | key : `str` 31 | A hex string identifying the user 32 | secret : `str` 33 | A hex string used to sign communications 34 | """ 35 | 36 | AccessToken = namedtuple("AccessToken", ['key', 'secret']) 37 | """ 38 | Represents an authorized user. This key and secret is provided by MediaWiki 39 | via ``Special:OAuth/complete`` and later used to show MediaWiki evidence of 40 | authorization. 41 | 42 | :Parameters: 43 | key : `str` 44 | A hex string identifying the user 45 | secret : `str` 46 | A hex string used to sign communications 47 | """ 48 | -------------------------------------------------------------------------------- /examples/request-oauthlib.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | from mwoauth import ConsumerToken, Handshaker 5 | from requests_oauthlib import OAuth1 6 | 7 | try: 8 | creds_doc = json.load(open("credentials.do_not_commit.json")) 9 | consumer_key = creds_doc['consumer_key'] 10 | consumer_secret = creds_doc['consumer_secret'] 11 | except FileNotFoundError: 12 | print('Couldn\'t find "credentials.do_not_commit.json". ' + 13 | 'Please manually input credentials.') 14 | consumer_key = input('Consumer key: ') 15 | consumer_secret = input('Consumer secret: ') 16 | 17 | consumer_token = ConsumerToken(consumer_key, consumer_secret) 18 | 19 | # Construct handshaker with wiki URI and consumer 20 | handshaker = Handshaker("https://en.wikipedia.org/w/index.php", 21 | consumer_token) 22 | 23 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for user 24 | redirect, request_token = handshaker.initiate() 25 | 26 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 27 | print("Point your browser to: %s" % redirect) # 28 | response_qs = input("Response query string: ") 29 | 30 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 31 | access_token = handshaker.complete(request_token, response_qs) 32 | 33 | # Construct an auth object with the consumer and access tokens 34 | auth1 = OAuth1(consumer_token.key, 35 | client_secret=consumer_token.secret, 36 | resource_owner_key=access_token.key, 37 | resource_owner_secret=access_token.secret) 38 | 39 | # Now, accessing the API on behalf of a user 40 | print("Reading top 10 watchlist items") 41 | response = requests.get( 42 | "https://en.wikipedia.org/w/api.php", 43 | params={ 44 | 'action': "query", 45 | 'list': "watchlist", 46 | 'wllimit': 10, 47 | 'wlprop': "title|comment", 48 | 'format': "json" 49 | }, 50 | auth=auth1 51 | ) 52 | doc = response.json() 53 | if 'error' in doc: 54 | print(doc['error']['code'], doc['error']['info']) 55 | else: 56 | for item in ['query']['watchlist']: 57 | print("{title}\t{comment}".format(**item)) 58 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. MediaWiki OAuth documentation master file, created by 2 | sphinx-quickstart on Wed May 14 18:52:32 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | MediaWiki OAuth Library Documentation 7 | ===================================== 8 | 9 | ``mwoauth`` is an open licensed (MIT) library designed to provide a simple means to performing an OAuth handshake with a MediaWiki installation with the `OAuth Extension `_ installed. 10 | 11 | **Compatible with python 2.7 and 3.x** 12 | 13 | **Install with pip:** ``pip install mwoauth`` 14 | 15 | There are two ways to use this library: 16 | 17 | * :class:`~mwoauth.Handshaker` -- an object oriented handshake manager 18 | * A set of stateless functions: :py:func:`~moauth.intitiate`, :py:func:`~moauth.complete` and :py:func:`~moauth.identify`. 19 | 20 | Both of strategies make use of the same set of tokens (:class:`~mwoauth.ConsumerToken`, :class:`~mwoauth.RequestToken` and :class:`~mwoauth.AccessToken`) and are totally inter-operable (if you like to mixing things up). 21 | 22 | There's also a :class:`flask.Blueprint` handler. See :mod:`mwoauth.flask`. 23 | 24 | The OAuth Handshaker 25 | ==================== 26 | 27 | .. automodule:: mwoauth.handshaker 28 | 29 | .. autoclass:: mwoauth.Handshaker 30 | :members: 31 | :member-order: bysource 32 | 33 | 34 | Tokens 35 | ====== 36 | 37 | .. automodule:: mwoauth.tokens 38 | 39 | .. autoclass:: mwoauth.ConsumerToken 40 | :members: 41 | :member-order: bysource 42 | 43 | .. autoclass:: mwoauth.RequestToken 44 | :members: 45 | :member-order: bysource 46 | 47 | .. autoclass:: mwoauth.AccessToken 48 | :members: 49 | :member-order: bysource 50 | 51 | 52 | Stateless functions 53 | =================== 54 | 55 | .. automodule:: mwoauth.functions 56 | 57 | .. autofunction:: mwoauth.initiate 58 | 59 | .. autofunction:: mwoauth.complete 60 | 61 | .. autofunction:: mwoauth.identify 62 | 63 | 64 | Flask Blueprint 65 | =============== 66 | 67 | .. automodule:: mwoauth.flask 68 | 69 | 70 | About 71 | ================= 72 | :authors: 73 | Aaron Halfaker (http://halfaker.info) • 74 | Filippo Valsorda (https://filippo.io) 75 | :repository: 76 | `mwoauth @ GitHub `_ 77 | :documentation: 78 | `mwoauth @ pythonhosted `_ 79 | 80 | Contributors 81 | ============ 82 | Pull requests are encouraged! 83 | 84 | Indices and tables 85 | ================== 86 | 87 | * :ref:`genindex` 88 | * :ref:`modindex` 89 | * :ref:`search` 90 | -------------------------------------------------------------------------------- /examples/flask_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from builtins import input 4 | 5 | import mwoauth 6 | import mwoauth.flask 7 | from flask import Flask, jsonify 8 | 9 | app = Flask(__name__) 10 | 11 | # Generate a random secret application key 12 | # 13 | # NOTE: this key changes every invocation. In an actual application, the key 14 | # should not change! Otherwise you might get a different secret key for 15 | # different requests, which means you can't read data stored in cookies, 16 | # which in turn breaks OAuth. 17 | # 18 | # So, for an actual application, use app.secret_key = "some long secret key" 19 | # (which you could generate using os.urandom(24)) 20 | # 21 | app.secret_key = os.urandom(24) 22 | 23 | print(""" 24 | NOTE: The callback URL you entered when proposing an OAuth consumer 25 | probably did not match the URL under which you are running this development 26 | server. Your redirect back will therefore fail -- please adapt the URL in 27 | your address bar to http://localhost:5000/oauth-callback?oauth_verifier=...etc 28 | """) 29 | 30 | try: 31 | creds_doc = json.load(open("credentials.do_not_commit.json")) 32 | consumer_key = creds_doc['consumer_key'] 33 | consumer_secret = creds_doc['consumer_secret'] 34 | except FileNotFoundError: 35 | print('Couldn\'t find "credentials.do_not_commit.json". ' + 36 | 'Please manually input credentials.') 37 | consumer_key = input('Consumer key: ') 38 | consumer_secret = input('Consumer secret: ') 39 | 40 | consumer_token = mwoauth.ConsumerToken(consumer_key, consumer_secret) 41 | flask_mwoauth = mwoauth.flask.MWOAuth( 42 | 'https://meta.wikimedia.org', consumer_token, 43 | user_agent="Demo mwoauth.flask server.") 44 | app.register_blueprint(flask_mwoauth.bp) 45 | 46 | 47 | @app.route("/") 48 | def index(): 49 | return "logged in as: " + \ 50 | repr((flask_mwoauth.identify() or {}).get('username')) + \ 51 | "
" + \ 52 | 'initiate / ' + \ 53 | 'identify / ' + \ 54 | 'my_recent_edits / ' + \ 55 | 'logout' 56 | 57 | 58 | @app.route("/my_recent_edits") 59 | @mwoauth.flask.authorized 60 | def my_recent_edits(): 61 | username = flask_mwoauth.identify()['username'] 62 | enwiki_session = flask_mwoauth.mwapi_session('https://en.wikipedia.org') 63 | doc = enwiki_session.get(action="query", list="usercontribs", 64 | ucuser=username, ucprop="timestamp", 65 | format="json") 66 | return jsonify(doc) 67 | 68 | if __name__ == "__main__": 69 | app.run(debug=True, threaded=True) 70 | -------------------------------------------------------------------------------- /mwoauth/handshaker.py: -------------------------------------------------------------------------------- 1 | """ 2 | A client for managing an OAuth handshake with MediaWiki. 3 | 4 | :Example: 5 | .. code-block:: python 6 | 7 | from mwoauth import ConsumerToken, Handshaker 8 | 9 | # Consruct a "consumer" from the key/secret provided by MediaWiki 10 | import config 11 | consumer_token = ConsumerToken( 12 | config.consumer_key, config.consumer_secret) 13 | 14 | # Construct handshaker with wiki URI and consumer 15 | handshaker = Handshaker( 16 | "https://en.wikipedia.org/w/index.php", consumer_token) 17 | 18 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for 19 | # user 20 | redirect, request_token = handshaker.initiate() 21 | 22 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 23 | print("Point your browser to: %s" % redirect) # 24 | response_qs = input("Response query string: ") 25 | 26 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 27 | access_token = handshaker.complete(request_token, response_qs) 28 | print(str(access_token)) 29 | 30 | # Step 4: Identify -- (optional) get identifying information about the 31 | # user 32 | identity = handshaker.identify(access_token) 33 | print("Identified as {username}.".format(**identity)) 34 | """ 35 | import logging 36 | 37 | from . import defaults 38 | from .functions import complete, identify, initiate 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | class Handshaker(object): 44 | """ 45 | 46 | :Parameters: 47 | mw_uri : `str` 48 | The base URI of the wiki (provider) to authenticate with. This uri 49 | should end in ``"index.php"``. 50 | consumer_token : :class:`~mwoauth.ConsumerToken` 51 | A token representing you, the consumer. Provided by MediaWiki via 52 | ``Special:OAuthConsumerRegistration``. 53 | callback : `str` 54 | Callback URL. Defaults to 'oob'. 55 | """ 56 | 57 | def __init__(self, mw_uri, consumer_token, callback='oob', 58 | user_agent=None): 59 | self.mw_uri = mw_uri 60 | self.consumer_token = consumer_token 61 | self.callback = callback 62 | if user_agent is None: 63 | logger.warning("Sending requests with default User-Agent. " + 64 | "Set 'user_agent' on mwoauth.flask.MWOAuth to " + 65 | "quiet this message.") 66 | self.user_agent = defaults.USER_AGENT 67 | else: 68 | self.user_agent = user_agent 69 | 70 | def initiate(self, callback=None): 71 | """ 72 | Initiate an OAuth handshake with MediaWiki. 73 | 74 | :Parameters: 75 | callback : `str` 76 | Callback URL. Defaults to 'oob'. 77 | 78 | :Returns: 79 | A `tuple` of two values: 80 | 81 | * a MediaWiki URL to direct the user to 82 | * a :class:`~mwoauth.RequestToken` representing an access request 83 | 84 | 85 | """ 86 | return initiate(self.mw_uri, self.consumer_token, 87 | callback=callback or self.callback, 88 | user_agent=self.user_agent) 89 | 90 | def complete(self, request_token, response_qs): 91 | """ 92 | Complete an OAuth handshake with MediaWiki by exchanging an 93 | 94 | :Parameters: 95 | request_token : `RequestToken` 96 | A temporary token representing the user. Returned by 97 | `initiate()`. 98 | response_qs : `bytes` 99 | The query string of the URL that MediaWiki forwards the user 100 | back after authorization. 101 | 102 | :Returns: 103 | An :class:`~mwoauth.AccessToken` containing an authorized 104 | key/secret pair that can be stored and used by you. 105 | """ 106 | return complete( 107 | self.mw_uri, self.consumer_token, request_token, response_qs, 108 | user_agent=self.user_agent) 109 | 110 | def identify(self, access_token, leeway=10.0): 111 | """ 112 | Gather identifying information about a user via an authorized token. 113 | 114 | :Parameters: 115 | access_token : `AccessToken` 116 | A token representing an authorized user. Obtained from 117 | `complete()`. 118 | leeway : `int` | `float` 119 | The number of seconds of leeway to account for when examining a 120 | tokens "issued at" timestamp. 121 | 122 | :Returns: 123 | A dictionary containing identity information. 124 | """ 125 | return identify(self.mw_uri, self.consumer_token, access_token, 126 | leeway=leeway, user_agent=self.user_agent) 127 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MediaWikiOAuth.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MediaWikiOAuth.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/MediaWikiOAuth" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MediaWikiOAuth" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # MediaWiki OAuth documentation build configuration file, created by 5 | # sphinx-quickstart on Wed May 14 18:52:32 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | 20 | def read(fname): 21 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 22 | 23 | # If extensions (or modules to document with autodoc) are in another directory, 24 | # add these directories to sys.path here. If the directory is relative to the 25 | # documentation root, use os.path.abspath to make it absolute, like shown here. 26 | sys.path.insert(0, os.path.abspath('../')) 27 | 28 | import mwoauth 29 | 30 | # -- General configuration ------------------------------------------------ 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | #needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'sphinx.ext.autodoc', 40 | 'sphinx.ext.intersphinx', 41 | 'sphinx.ext.coverage', 42 | 'sphinx.ext.mathjax', 43 | 'sphinx.ext.ifconfig', 44 | 'sphinx.ext.viewcode', 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # The suffix of source filenames. 51 | source_suffix = '.rst' 52 | 53 | # The encoding of source files. 54 | #source_encoding = 'utf-8-sig' 55 | 56 | # The master toctree document. 57 | master_doc = 'index' 58 | 59 | # General information about the project. 60 | project = 'MediaWiki OAuth' 61 | copyright = '2014, Aaron Halfaker & Filippo Valsorda' 62 | 63 | # The version info for the project you're documenting, acts as replacement for 64 | # |version| and |release|, also used in various other places throughout the 65 | # built documents. 66 | # 67 | # The short X.Y version. 68 | version = mwoauth.__version__ 69 | release = version 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | #language = None 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | #today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | #today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = ['_build'] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all 86 | # documents. 87 | #default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | #add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | #add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | #show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = 'sphinx' 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | #modindex_common_prefix = [] 105 | 106 | # If true, keep warnings as "system message" paragraphs in the built documents. 107 | #keep_warnings = False 108 | 109 | 110 | # -- Options for HTML output ---------------------------------------------- 111 | 112 | # The theme to use for HTML and HTML Help pages. See the documentation for 113 | # a list of builtin themes. 114 | html_theme = 'alabaster' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # Add any extra paths that contain custom files (such as robots.txt or 146 | # .htaccess) here, relative to this directory. These files are copied 147 | # directly to the root of the documentation. 148 | #html_extra_path = [] 149 | 150 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 151 | # using the given strftime format. 152 | #html_last_updated_fmt = '%b %d, %Y' 153 | 154 | # If true, SmartyPants will be used to convert quotes and dashes to 155 | # typographically correct entities. 156 | #html_use_smartypants = True 157 | 158 | # Custom sidebar templates, maps document names to template names. 159 | #html_sidebars = {} 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'MediaWikiOAuthdoc' 193 | 194 | 195 | # -- Options for LaTeX output --------------------------------------------- 196 | 197 | latex_elements = { 198 | # The paper size ('letterpaper' or 'a4paper'). 199 | #'papersize': 'letterpaper', 200 | 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | #'pointsize': '10pt', 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | #'preamble': '', 206 | } 207 | 208 | # Grouping the document tree into LaTeX files. List of tuples 209 | # (source start file, target name, title, 210 | # author, documentclass [howto, manual, or own class]). 211 | latex_documents = [ 212 | ('index', 'MediaWikiOAuth.tex', 'MediaWiki OAuth Documentation', 213 | 'Aaron Halfaker \\& Filippo Valsorda', 'manual'), 214 | ] 215 | 216 | # The name of an image file (relative to this directory) to place at the top of 217 | # the title page. 218 | #latex_logo = None 219 | 220 | # For "manual" documents, if this is true, then toplevel headings are parts, 221 | # not chapters. 222 | #latex_use_parts = False 223 | 224 | # If true, show page references after internal links. 225 | #latex_show_pagerefs = False 226 | 227 | # If true, show URL addresses after external links. 228 | #latex_show_urls = False 229 | 230 | # Documents to append as an appendix to all manuals. 231 | #latex_appendices = [] 232 | 233 | # If false, no module index is generated. 234 | #latex_domain_indices = True 235 | 236 | 237 | # -- Options for manual page output --------------------------------------- 238 | 239 | # One entry per manual page. List of tuples 240 | # (source start file, name, description, authors, manual section). 241 | man_pages = [ 242 | ('index', 'mediawikioauth', 'MediaWiki OAuth Documentation', 243 | ['Aaron Halfaker & Filippo Valsorda'], 1) 244 | ] 245 | 246 | # If true, show URL addresses after external links. 247 | #man_show_urls = False 248 | 249 | 250 | # -- Options for Texinfo output ------------------------------------------- 251 | 252 | # Grouping the document tree into Texinfo files. List of tuples 253 | # (source start file, target name, title, author, 254 | # dir menu entry, description, category) 255 | texinfo_documents = [ 256 | ('index', 'MediaWikiOAuth', 'MediaWiki OAuth Documentation', 257 | 'Aaron Halfaker & Filippo Valsorda', 'MediaWikiOAuth', 'One line description of project.', 258 | 'Miscellaneous'), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | #texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | #texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | #texinfo_show_urls = 'footnote' 269 | 270 | # If true, do not generate a @detailmenu in the "Top" node's menu. 271 | #texinfo_no_detailmenu = False 272 | 273 | 274 | # Example configuration for intersphinx: refer to the Python standard library. 275 | intersphinx_mapping = { 276 | 'http://docs.python.org/': None, 277 | 'http://pythonhosted.org/mwapi/': None, 278 | 'http://docs.python-requests.org/en/master/': None} 279 | -------------------------------------------------------------------------------- /mwoauth/flask.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. autoclass:: mwoauth.flask.MWOAuth 3 | :members: 4 | :member-order: bysource 5 | 6 | .. autofunction:: mwoauth.flask.authorized 7 | """ 8 | 9 | import logging 10 | from functools import wraps 11 | from urllib.parse import urljoin 12 | 13 | import flask 14 | from requests_oauthlib import OAuth1 15 | 16 | from .errors import OAuthException 17 | from .handshaker import Handshaker 18 | from .tokens import AccessToken, RequestToken 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class MWOAuth: 24 | """ 25 | Implements a basic MediaWiki OAuth pattern with a set of routes 26 | 27 | - /mwoauth/initiate -- Starts an OAuth handshake 28 | - /mwoauth/callback -- Completes an OAuth handshake 29 | - /mwoauth/identify -- Gets identity information about an authorized user 30 | - /mwoauth/logout -- Discards OAuth tokens and user identity 31 | 32 | There's also a convenient decorator provided 33 | :func:`~mwoauth.flask.MWOAuth.authorized`. When applied to a routing 34 | function, this decorator will redirect non-authorized users to 35 | /mwoauth/initiate with a "?next=" that will return them to the originating 36 | route once authorization is completed. 37 | 38 | :Example: 39 | .. code-block:: python 40 | 41 | from flask import Flask 42 | import mwoauth 43 | import mwoauth.flask 44 | 45 | app = Flask(__name__) 46 | 47 | @app.route("/") 48 | def index(): 49 | return "Hello world" 50 | 51 | flask_mwoauth = mwoauth.flask.MWOAuth( 52 | "https://en.wikipedia.org", 53 | mwoauth.ConsumerToken("...", "...")) 54 | app.register_blueprint(flask_mwoauth.bp) 55 | 56 | @app.route("/my_settings/") 57 | @mwoauth.flask.authorized 58 | def my_settings(): 59 | return flask_mwoauth.identity() 60 | 61 | :Parameters: 62 | host : str 63 | The host name (including protocol) of the MediaWiki wiki to use 64 | for the OAuth handshake. 65 | consumer_token : :class:`mwoauth.ConsumerToken` 66 | The consumer token information 67 | user_agent : str 68 | A User-Agent header to include with requests. A warning will be 69 | logged is this is not set. 70 | default_next : str 71 | Where should the user be redirected after an OAuth handshake when 72 | no '?next=' param is provided 73 | render_logout : func 74 | A method that renders the logout page seen at /mwoauth/logout 75 | render_identify : func 76 | A method that renders the identify page seen at /mwoauth/identify. 77 | Takes one positional argument -- the `identity` dictionary returned 78 | by MediaWiki. 79 | render_error : func 80 | A method that renders an error. Takes two arguements: 81 | 82 | * message : str (The error message) 83 | * status : int (The https status number) 84 | **kwargs : dict 85 | Parameters to be passed to :class:`flask.Blueprint` during 86 | its construction. 87 | """ 88 | def __init__(self, host, consumer_token, user_agent=None, 89 | default_next="index", 90 | render_logout=None, render_indentify=None, render_error=None, 91 | **kwargs): 92 | 93 | self.bp = flask.Blueprint('mwoauth', __name__, **kwargs) 94 | self.host = host 95 | self.user_agent = user_agent 96 | self.consumer_token = consumer_token 97 | self.handshaker = None 98 | self.default_next = default_next 99 | self.render_logout = render_logout or generic_logout 100 | self.render_identify = render_indentify or generic_identify 101 | self.render_error = render_error or generic_error 102 | 103 | @self.bp.route("/mwoauth/initiate/") 104 | def mwoauth_initiate(): 105 | """Start an OAuth handshake.""" 106 | mw_authorizer_url, request_token = self._handshaker().initiate() 107 | rt_session_key = _str(request_token.key) + "_request_token" 108 | next_session_key = _str(request_token.key) + "_next" 109 | 110 | # Ensures that Flask's default session storage strategy will work 111 | flask.session[rt_session_key] = \ 112 | dict(zip(request_token._fields, request_token)) 113 | 114 | if 'next' in flask.request.args: 115 | flask.session[next_session_key] = \ 116 | flask.request.args.get('next') 117 | 118 | return flask.redirect(mw_authorizer_url) 119 | 120 | @self.bp.route("/mwoauth/callback/") 121 | def mwoauth_callback(): 122 | """Complete the oauth handshake.""" 123 | # Generate session keys 124 | request_token_key = _str( 125 | flask.request.args.get('oauth_token', 'None')) 126 | rt_session_key = request_token_key + "_request_token" 127 | next_session_key = request_token_key + "_next" 128 | 129 | # Make sure we're continuing an in-progress handshake 130 | if rt_session_key not in flask.session: 131 | flask.session.pop(rt_session_key, None) 132 | flask.session.pop(next_session_key, None) 133 | return self.render_error( 134 | "OAuth callback failed. " + 135 | "Couldn't find request_token in session. " + 136 | "Are cookies disabled?", 403) 137 | 138 | # Complete the handshake 139 | try: 140 | access_token = self._handshaker().complete( 141 | RequestToken(**flask.session[rt_session_key]), 142 | _str(flask.request.query_string)) 143 | except OAuthException as e: 144 | flask.session.pop(rt_session_key, None) 145 | flask.session.pop(next_session_key, None) 146 | return self.render_error( 147 | "OAuth callback failed. " + str(e), 403) 148 | 149 | # Store the access token 150 | flask.session['mwoauth_access_token'] = \ 151 | dict(zip(access_token._fields, access_token)) 152 | 153 | # Identify the user 154 | identity = self._handshaker().identify(access_token) 155 | flask.session['mwoauth_identity'] = identity 156 | 157 | # Redirect to wherever we're supposed to go 158 | if next_session_key in flask.session: 159 | return flask.redirect( 160 | flask.url_for(flask.session[next_session_key])) 161 | else: 162 | return flask.redirect( 163 | flask.url_for(self.default_next)) 164 | 165 | @self.bp.route("/mwoauth/identify/") 166 | @authorized 167 | def mwoauth_identify(): 168 | """Return user information if authenticated.""" 169 | return flask.jsonify(flask.session['mwoauth_identity']) 170 | 171 | @self.bp.route("/mwoauth/logout/") 172 | def mwoauth_logout(): 173 | """Delete the local session.""" 174 | flask.session.pop('mwoauth_access_token', None) 175 | flask.session.pop('mwoauth_identity', None) 176 | 177 | if 'next' in flask.request.args: 178 | return flask.redirect( 179 | flask.url_for(flask.request.args.get('next'))) 180 | else: 181 | return self.render_logout() 182 | 183 | def _handshaker(self): 184 | if not self.handshaker: 185 | full_callback = urljoin( 186 | flask.request.url_root, 187 | flask.url_for("mwoauth.mwoauth_callback")) 188 | print(full_callback) 189 | self.handshaker = Handshaker( 190 | self.host, self.consumer_token, user_agent=self.user_agent, 191 | callback=full_callback) 192 | 193 | return self.handshaker 194 | 195 | @staticmethod 196 | def identify(): 197 | return flask.session.get('mwoauth_identity') 198 | 199 | def mwapi_session(self, *args, **kwargs): 200 | """ 201 | Create :class:`mwapi.Session` that is authorized for the current 202 | user. 203 | 204 | `args` and `kwargs` are passed directly to :class:`mwapi.Session` 205 | """ 206 | import mwapi 207 | auth1 = self.generate_auth() 208 | return mwapi.Session(*args, user_agent=self.user_agent, auth=auth1, 209 | **kwargs) 210 | 211 | def requests_session(self, *args, **kwargs): 212 | """ 213 | Create :class:`requests.Session` that is authorized for the current 214 | user. 215 | 216 | `args` and `kwargs` are passed directly to :class:`requests.Session` 217 | """ 218 | import requests 219 | auth1 = self.generate_auth() 220 | return requests.Session(*args, auth=auth1, **kwargs) 221 | 222 | def generate_auth(self): 223 | if 'mwoauth_access_token' in flask.session: 224 | access_token = AccessToken( 225 | **flask.session['mwoauth_access_token']) 226 | auth1 = OAuth1(self.consumer_token.key, 227 | client_secret=self.consumer_token.secret, 228 | resource_owner_key=access_token.key, 229 | resource_owner_secret=access_token.secret) 230 | return auth1 231 | else: 232 | raise OAuthException( 233 | "Cannot generate auth. User has not authorized.") 234 | 235 | 236 | def authorized(route): 237 | """ 238 | Wrap a flask route. Ensure that the user has authorized via OAuth or 239 | redirect the user to the authorization endpoint with a delayed redirect 240 | back to the originating endpoint. 241 | """ 242 | @wraps(route) 243 | def authorized_route(*args, **kwargs): 244 | if 'mwoauth_access_token' in flask.session: 245 | return route(*args, **kwargs) 246 | else: 247 | return flask.redirect( 248 | flask.url_for('mwoauth.mwoauth_initiate') + 249 | "?next=" + flask.request.endpoint) 250 | 251 | return authorized_route 252 | 253 | 254 | def generic_logout(): 255 | return "Logged out" 256 | 257 | 258 | def generic_identify(identity): 259 | return flask.jsonify(identity) 260 | 261 | 262 | def generic_error(message, status): 263 | return '' + message + '', status 264 | 265 | 266 | def encode_token(token): 267 | return dict(zip(token._fields, token)) 268 | 269 | 270 | def _str(val): 271 | """Ensure that the val is the default str() type for python2 or 3.""" 272 | if str == bytes: 273 | if isinstance(val, str): 274 | return val 275 | else: 276 | return str(val) 277 | else: 278 | if isinstance(val, str): 279 | return val 280 | else: 281 | return str(val, 'ascii') 282 | -------------------------------------------------------------------------------- /mwoauth/functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of stateless functions that can be used to complete various steps of an 3 | OAuth handshake or to identify a MediaWiki user. 4 | 5 | :Example: 6 | .. code-block:: python 7 | 8 | from mwoauth import ConsumerToken, initiate, complete, identify 9 | 10 | # Consruct a "consumer" from the key/secret provided by MediaWiki 11 | import config 12 | consumer_token = ConsumerToken( 13 | config.consumer_key, config.consumer_secret) 14 | mw_uri = "https://en.wikipedia.org/w/index.php" 15 | 16 | # Step 1: Initialize -- ask MediaWiki for a temporary key/secret for 17 | # user 18 | redirect, request_token = initiate(mw_uri, consumer_token) 19 | 20 | # Step 2: Authorize -- send user to MediaWiki to confirm authorization 21 | print("Point your browser to: %s" % redirect) # 22 | response_qs = input("Response query string: ") 23 | 24 | # Step 3: Complete -- obtain authorized key/secret for "resource owner" 25 | access_token = complete( 26 | mw_uri, consumer_token, request_token, response_qs) 27 | print(str(access_token)) 28 | 29 | # Step 4: Identify -- (optional) get identifying information about the 30 | # user 31 | identity = identify(mw_uri, consumer_token, access_token) 32 | print("Identified as {username}.".format(**identity)) 33 | """ 34 | import json 35 | import re 36 | import time 37 | from urllib.parse import parse_qs, urlencode, urlparse 38 | 39 | import jwt 40 | import requests 41 | from requests_oauthlib import OAuth1 42 | 43 | from . import defaults 44 | from .errors import OAuthException 45 | from .tokens import AccessToken, RequestToken 46 | 47 | 48 | def force_unicode(val, encoding="unicode-escape"): 49 | if type(val) == str: 50 | return val 51 | else: 52 | return val.decode(encoding, errors="replace") 53 | 54 | 55 | def initiate(mw_uri, consumer_token, callback='oob', 56 | user_agent=defaults.USER_AGENT): 57 | """ 58 | Initiate an oauth handshake with MediaWiki. 59 | 60 | :Parameters: 61 | mw_uri : `str` 62 | The base URI of the MediaWiki installation. Note that the URI 63 | should end in ``"index.php"``. 64 | consumer_token : :class:`~mwoauth.ConsumerToken` 65 | A token representing you, the consumer. Provided by MediaWiki via 66 | ``Special:OAuthConsumerRegistration``. 67 | callback : `str` 68 | Callback URL. Defaults to 'oob'. 69 | 70 | :Returns: 71 | A `tuple` of two values: 72 | 73 | * a MediaWiki URL to direct the user to 74 | * a :class:`~mwoauth.RequestToken` representing a request for access 75 | 76 | 77 | """ 78 | auth = OAuth1(consumer_token.key, 79 | client_secret=consumer_token.secret, 80 | callback_uri=callback) 81 | 82 | r = requests.post(url=mw_uri, 83 | params={'title': "Special:OAuth/initiate"}, 84 | auth=auth, 85 | headers={'User-Agent': user_agent}) 86 | 87 | request_token = process_request_token(r.text) 88 | 89 | params = {'title': "Special:OAuth/authenticate", 90 | 'oauth_token': request_token.key, 91 | 'oauth_consumer_key': consumer_token.key} 92 | 93 | return (mw_uri + "?" + urlencode(params), request_token) 94 | 95 | 96 | def process_request_token(content): 97 | if content.startswith("Error: "): 98 | raise OAuthException(content[len("Error: "):]) 99 | 100 | credentials = parse_qs(content) 101 | 102 | if credentials is None or credentials == {}: 103 | raise OAuthException( 104 | "Expected x-www-form-urlencoded response from " + 105 | "MediaWiki, but got something else: " + 106 | "{0}".format(repr(content))) 107 | elif 'oauth_token' not in credentials or \ 108 | 'oauth_token_secret' not in credentials: 109 | raise OAuthException( 110 | "MediaWiki response lacks token information: " 111 | "{0}".format(repr(credentials))) 112 | else: 113 | return RequestToken( 114 | credentials.get('oauth_token')[0], 115 | credentials.get('oauth_token_secret')[0] 116 | ) 117 | 118 | 119 | def complete(mw_uri, consumer_token, request_token, response_qs, 120 | user_agent=defaults.USER_AGENT): 121 | """ 122 | Complete an OAuth handshake with MediaWiki by exchanging an 123 | 124 | :Parameters: 125 | mw_uri : `str` 126 | The base URI of the MediaWiki installation. Note that the URI 127 | should end in ``"index.php"``. 128 | consumer_token : :class:`~mwoauth.ConsumerToken` 129 | A key/secret pair representing you, the consumer. 130 | request_token : :class:`~mwoauth.RequestToken` 131 | A temporary token representing the user. Returned by 132 | `initiate()`. 133 | response_qs : `bytes` 134 | The query string of the URL that MediaWiki forwards the user back 135 | after authorization. 136 | 137 | :Returns: 138 | An `AccessToken` containing an authorized key/secret pair that 139 | can be stored and used by you. 140 | """ 141 | 142 | callback_data = parse_qs(force_unicode(response_qs)) 143 | 144 | if callback_data is None or callback_data == {}: 145 | raise OAuthException( 146 | "Expected URL query string, but got " + 147 | "something else instead: {0}".format(str(response_qs))) 148 | 149 | elif 'oauth_token' not in callback_data or \ 150 | 'oauth_verifier' not in callback_data: 151 | raise OAuthException( 152 | "Query string lacks token information: " 153 | "{0}".format(repr(callback_data))) 154 | 155 | # Process the callback_data 156 | request_token_key = callback_data.get('oauth_token')[0] 157 | verifier = callback_data.get('oauth_verifier')[0] 158 | 159 | # Check if the query string references the right temp resource owner key 160 | if not request_token.key == request_token_key: 161 | raise OAuthException( 162 | "Unexpect request token key {0!r}, expected {1!r}.".format( 163 | request_token_key, request_token.key)) 164 | 165 | # Construct a new auth with the verifier 166 | auth = OAuth1(consumer_token.key, 167 | client_secret=consumer_token.secret, 168 | resource_owner_key=request_token.key, 169 | resource_owner_secret=request_token.secret, 170 | verifier=verifier) 171 | 172 | # Send the verifier and ask for an authorized resource owner key/secret 173 | r = requests.post(url=mw_uri, 174 | params={'title': "Special:OAuth/token"}, 175 | auth=auth, 176 | headers={'User-Agent': user_agent}) 177 | 178 | # Parse response and construct an authorized resource owner 179 | credentials = parse_qs(r.text) 180 | 181 | if credentials is None: 182 | raise OAuthException( 183 | "Expected x-www-form-urlencoded response, " + 184 | "but got some else instead: {0}".format(r.text)) 185 | 186 | access_token = AccessToken( 187 | credentials.get('oauth_token')[0], 188 | credentials.get('oauth_token_secret')[0] 189 | ) 190 | 191 | return access_token 192 | 193 | 194 | def identify(mw_uri, consumer_token, access_token, leeway=10.0, 195 | user_agent=defaults.USER_AGENT): 196 | """ 197 | Gather identifying information about a user via an authorized token. 198 | 199 | :Parameters: 200 | mw_uri : `str` 201 | The base URI of the MediaWiki installation. Note that the URI 202 | should end in ``"index.php"``. 203 | consumer_token : :class:`~mwoauth.ConsumerToken` 204 | A token representing you, the consumer. 205 | access_token : :class:`~mwoauth.AccessToken` 206 | A token representing an authorized user. Obtained from 207 | `complete()` 208 | leeway : `int` | `float` 209 | The number of seconds of leeway to account for when examining a 210 | tokens "issued at" timestamp. 211 | 212 | :Returns: 213 | A dictionary containing identity information. 214 | """ 215 | 216 | # Construct an OAuth auth 217 | auth = OAuth1(consumer_token.key, 218 | client_secret=consumer_token.secret, 219 | resource_owner_key=access_token.key, 220 | resource_owner_secret=access_token.secret) 221 | 222 | # Request the identity using auth 223 | r = requests.post(url=mw_uri, 224 | params={'title': "Special:OAuth/identify"}, 225 | auth=auth, 226 | headers={'User-Agent': user_agent}) 227 | 228 | # Special:OAuth/identify unhelpfully returns 200 status even when there is 229 | # an error in the API call. Check for error messages manually. 230 | try: 231 | identity = jwt.decode(r.content, consumer_token.secret, 232 | audience=consumer_token.key, 233 | algorithms=["HS256"], 234 | leeway=leeway) 235 | except jwt.InvalidTokenError as e: 236 | if r.text.startswith('{'): 237 | try: 238 | resp = json.loads(r.text) 239 | if 'error' in resp: 240 | raise OAuthException( 241 | "A MediaWiki API error occurred: {0}" 242 | .format(resp['message'])) 243 | else: 244 | raise OAuthException( 245 | "Unknown JSON response: {0!r}" 246 | .format(r.text[:100])) 247 | except ValueError as e: 248 | raise OAuthException( 249 | "An error occurred while trying to read json " + 250 | "content: {0}".format(e)) 251 | else: 252 | raise OAuthException( 253 | "Could not read response from 'Special:OAuth/identify'. " + 254 | "Maybe your MediaWiki is not configured correctly? " + 255 | "Expected JSON but instead got: {0!r}".format(r.text[:100])) 256 | 257 | # Verify the issuer is who we expect (server sends $wgCanonicalServer) 258 | issuer = urlparse(identity['iss']).netloc 259 | expected_domain = urlparse(mw_uri).netloc 260 | if not issuer == expected_domain: 261 | raise OAuthException( 262 | "Unexpected issuer " + 263 | "{0}, expected {1}".format(issuer, expected_domain)) 264 | 265 | # Check that the identity was issued in the past. 266 | now = time.time() 267 | issued_at = float(identity['iat']) 268 | if not now >= (issued_at - leeway): 269 | raise OAuthException( 270 | "Identity issued {0} ".format(issued_at - now) + 271 | "seconds in the future!") 272 | 273 | # Verify that the nonce matches our request nonce, 274 | # to avoid a replay attack 275 | authorization_header = force_unicode(r.request.headers['Authorization']) 276 | request_nonce = re.search(r'oauth_nonce="(.*?)"', 277 | authorization_header).group(1) 278 | if identity['nonce'] != request_nonce: 279 | raise OAuthException( 280 | 'Replay attack detected: {0} != {1}'.format( 281 | identity['nonce'], request_nonce)) 282 | 283 | return identity 284 | --------------------------------------------------------------------------------