├── .gitignore ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── _static │ └── oauth2.png ├── conf.py ├── index.rst └── make.bat ├── setup.cfg ├── setup.py └── wsgioauth2.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | .idea 3 | *.pyc 4 | MANIFEST 5 | build 6 | dist 7 | docs/_build 8 | wsgi_oauth2.egg-info 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011-2014 by Hong Minhee 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | wsgi-oauth2 2 | =========== 3 | 4 | .. image:: docs/_static/oauth2.png 5 | 6 | This module provides a simple WSGI middleware that requires the user to 7 | authenticate via the specific `OAuth 2.0`_ service, e.g., Facebook_, Google_, 8 | GitHub_. 9 | 10 | You can install the package via downloading from PyPI_: 11 | 12 | .. code-block:: console 13 | 14 | $ pip install wsgi-oauth2 15 | 16 | Read the documentation_ as well. 17 | 18 | .. _OAuth 2.0: https://oauth.net/2/ 19 | .. _Facebook: https://www.facebook.com/ 20 | .. _Google: https://www.google.com/ 21 | .. _GitHub: https://github.com/ 22 | .. _PyPI: https://pypi.org/project/wsgi-oauth2/ 23 | .. _documentation: https://dahlia.github.io/wsgi-oauth2/ 24 | 25 | -------------------------------------------------------------------------------- /docs/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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/wsgi-oauth2.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/wsgi-oauth2.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/wsgi-oauth2" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/wsgi-oauth2" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/_static/oauth2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahlia/wsgi-oauth2/ee8938b245830801a4a71c4351582a71acd4b6e6/docs/_static/oauth2.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # wsgi-oauth2 documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Nov 4 07:40:13 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('..')) 20 | import wsgioauth2 21 | 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 31 | 'sphinx.ext.extlinks'] 32 | 33 | # Add any paths that contain templates here, relative to this directory. 34 | templates_path = ['_templates'] 35 | 36 | # The suffix of source filenames. 37 | source_suffix = '.rst' 38 | 39 | # The encoding of source files. 40 | #source_encoding = 'utf-8-sig' 41 | 42 | # The master toctree document. 43 | master_doc = 'index' 44 | 45 | # General information about the project. 46 | project = u'wsgi-oauth2' 47 | copyright = wsgioauth2.__copyright__ 48 | 49 | # The version info for the project you're documenting, acts as replacement for 50 | # |version| and |release|, also used in various other places throughout the 51 | # built documents. 52 | # 53 | # The short X.Y version. 54 | version = wsgioauth2.__version__ 55 | # The full version, including alpha/beta/rc tags. 56 | release = wsgioauth2.__version__ 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'default' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | html_theme_options = { 103 | 'nosidebar': True, 104 | 'footerbgcolor': '#4c1b1b', 105 | 'relbarbgcolor': '#b9121b', 106 | 'headtextcolor': '#4c1b1b', 107 | 'linkcolor': '#b9121b', 108 | 'visitedlinkcolor': '#bd8d46', 109 | 'codebgcolor': '#fcfae1', 110 | 'codetextcolor': 'black' 111 | } 112 | 113 | # Add any paths that contain custom themes here, relative to this directory. 114 | #html_theme_path = [] 115 | 116 | # The name for this set of Sphinx documents. If None, it defaults to 117 | # " v documentation". 118 | html_title = project + ' v' + version 119 | 120 | # A shorter title for the navigation bar. Default is the same as html_title. 121 | #html_short_title = None 122 | 123 | # The name of an image file (relative to this directory) to place at the top 124 | # of the sidebar. 125 | #html_logo = None 126 | 127 | # The name of an image file (within the static path) to use as favicon of the 128 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 129 | # pixels large. 130 | #html_favicon = None 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | html_static_path = ['_static'] 136 | 137 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 138 | # using the given strftime format. 139 | #html_last_updated_fmt = '%b %d, %Y' 140 | 141 | # If true, SmartyPants will be used to convert quotes and dashes to 142 | # typographically correct entities. 143 | #html_use_smartypants = True 144 | 145 | # Custom sidebar templates, maps document names to template names. 146 | #html_sidebars = {} 147 | 148 | # Additional templates that should be rendered to pages, maps page names to 149 | # template names. 150 | #html_additional_pages = {} 151 | 152 | # If false, no module index is generated. 153 | #html_domain_indices = True 154 | 155 | # If false, no index is generated. 156 | #html_use_index = True 157 | 158 | # If true, the index is split into individual pages for each letter. 159 | #html_split_index = False 160 | 161 | # If true, links to the reST sources are added to the pages. 162 | #html_show_sourcelink = True 163 | 164 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 165 | #html_show_sphinx = True 166 | 167 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 168 | #html_show_copyright = True 169 | 170 | # If true, an OpenSearch description file will be output, and all pages will 171 | # contain a tag referring to it. The value of this option must be the 172 | # base URL from which the finished HTML is served. 173 | #html_use_opensearch = '' 174 | 175 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 176 | #html_file_suffix = None 177 | 178 | # Output file base name for HTML help builder. 179 | htmlhelp_basename = 'wsgi-oauth2doc' 180 | 181 | 182 | # -- Options for LaTeX output -------------------------------------------------- 183 | 184 | latex_elements = { 185 | # The paper size ('letterpaper' or 'a4paper'). 186 | #'papersize': 'letterpaper', 187 | 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | 191 | # Additional stuff for the LaTeX preamble. 192 | #'preamble': '', 193 | } 194 | 195 | # Grouping the document tree into LaTeX files. List of tuples 196 | # (source start file, target name, title, author, documentclass [howto/manual]). 197 | latex_documents = [ 198 | ('index', 'wsgi-oauth2.tex', u'wsgi-oauth2 Documentation', 199 | wsgioauth2.__author__, 'manual'), 200 | ] 201 | 202 | # The name of an image file (relative to this directory) to place at the top of 203 | # the title page. 204 | #latex_logo = None 205 | 206 | # For "manual" documents, if this is true, then toplevel headings are parts, 207 | # not chapters. 208 | #latex_use_parts = False 209 | 210 | # If true, show page references after internal links. 211 | #latex_show_pagerefs = False 212 | 213 | # If true, show URL addresses after external links. 214 | #latex_show_urls = False 215 | 216 | # Documents to append as an appendix to all manuals. 217 | #latex_appendices = [] 218 | 219 | # If false, no module index is generated. 220 | #latex_domain_indices = True 221 | 222 | 223 | # -- Options for manual page output -------------------------------------------- 224 | 225 | # One entry per manual page. List of tuples 226 | # (source start file, name, description, authors, manual section). 227 | man_pages = [ 228 | ('index', 'wsgi-oauth2', u'wsgi-oauth2 Documentation', 229 | [wsgioauth2.__author__], 1) 230 | ] 231 | 232 | # If true, show URL addresses after external links. 233 | #man_show_urls = False 234 | 235 | 236 | # -- Options for Texinfo output ------------------------------------------------ 237 | 238 | # Grouping the document tree into Texinfo files. List of tuples 239 | # (source start file, target name, title, author, 240 | # dir menu entry, description, category) 241 | texinfo_documents = [ 242 | ('index', 'wsgi-oauth2', u'wsgi-oauth2 Documentation', 243 | wsgioauth2.__author__, 'wsgi-oauth2', 'One line description of project.', 244 | 'Miscellaneous'), 245 | ] 246 | 247 | # Documents to append as an appendix to all manuals. 248 | #texinfo_appendices = [] 249 | 250 | # If false, no module index is generated. 251 | #texinfo_domain_indices = True 252 | 253 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 254 | #texinfo_show_urls = 'footnote' 255 | 256 | 257 | # Example configuration for intersphinx: refer to the Python standard library. 258 | intersphinx_mapping = {'http://docs.python.org/': None, 259 | 'http://simplejson.readthedocs.org/en/latest/': None} 260 | 261 | extlinks = { 262 | 'pull': ('https://github.com/dahlia/wsgi-oauth2/pull/%s', 263 | 'pull request #'), 264 | 'issue': ('https://github.com/dahlia/wsgi-oauth2/issues/%s', 265 | 'issue #') 266 | } 267 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. wsgi-oauth2 documentation master file, created by 2 | sphinx-quickstart on Fri Nov 4 07:40:13 2011. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | wsgi-oauth2 7 | =========== 8 | 9 | .. image:: _static/oauth2.png 10 | 11 | This module provides a simple WSGI middleware that requires the user to 12 | authenticate via the specific `OAuth 2.0`_ service e.g. Facebook_, Google_, 13 | GitHub_. 14 | 15 | .. _OAuth 2.0: http://oauth.net/2/ 16 | .. _Facebook: http://www.facebook.com/ 17 | .. _Google: http://www.google.com/ 18 | .. _GitHub: https://github.com/ 19 | 20 | 21 | Prerequisites 22 | ------------- 23 | 24 | It requires Python 2.6, 2.7, 3.2 or higher. 25 | It has no dependencies for non standard libraries, but if there is an installed 26 | :mod:`simplejson` library, it will be used instead of the standard :mod:`json` 27 | package. 28 | 29 | 30 | Installation 31 | ------------ 32 | 33 | You can install the package via downloading from PyPI_: 34 | 35 | .. sourcecode:: console 36 | 37 | $ pip install wsgi-oauth2 38 | 39 | If you want to use the bleeding edge, install it from the :ref:`Git repository 40 | `: 41 | 42 | .. sourcecode:: console 43 | 44 | $ pip install git+git://github.com/dahlia/wsgi-oauth2.git 45 | 46 | .. _PyPI: http://pypi.python.org/pypi/wsgi-oauth2 47 | 48 | 49 | Predefined services 50 | ------------------- 51 | 52 | There are some predefined services. 53 | 54 | .. autodata:: wsgioauth2.google 55 | 56 | .. autodata:: wsgioauth2.facebook 57 | 58 | .. autodata:: wsgioauth2.github 59 | 60 | 61 | Basic usage 62 | ----------- 63 | 64 | :: 65 | 66 | from myapp import app 67 | from wsgioauth2 import github 68 | 69 | client = github.make_client(client_id='...', client_secret='...') 70 | app = client.wsgi_middleware(app, secret='hmac*secret') 71 | 72 | 73 | .. module:: wsgioauth2 74 | 75 | :mod:`wsgioauth2` --- API references 76 | ------------------------------------ 77 | 78 | .. autoclass:: wsgioauth2.Service 79 | :members: 80 | 81 | .. autoclass:: wsgioauth2.Client 82 | :members: 83 | 84 | .. autoclass:: wsgioauth2.AccessToken 85 | :members: 86 | 87 | .. autoclass:: wsgioauth2.WSGIMiddleware 88 | :members: 89 | 90 | .. autoclass:: wsgioauth2.GitHubService 91 | :members: 92 | 93 | 94 | .. _sourcecode: 95 | 96 | Source code 97 | ----------- 98 | 99 | The source code is available under MIT license. Check out from the GitHub__: 100 | 101 | .. sourcecode:: console 102 | 103 | $ git clone git://github.com/dahlia/wsgi-oauth2.git 104 | 105 | We welcome pull requests as well! 106 | 107 | __ https://github.com/dahlia/wsgi-oauth2 108 | 109 | 110 | Bugs 111 | ---- 112 | 113 | If you found bugs or want to propose some improvement ideas, use the 114 | `issue tracker`_. 115 | 116 | .. _issue tracker: https://github.com/dahlia/wsgi-oauth2/issues 117 | 118 | 119 | Author 120 | ------ 121 | 122 | The package is written by `Hong Minhee`_. 123 | 124 | .. _Hong Minhee: https://hongminhee.org/ 125 | 126 | 127 | Changelog 128 | --------- 129 | 130 | Version 0.2.3 131 | ''''''''''''' 132 | 133 | To be released. 134 | 135 | 136 | Version 0.2.2 137 | ''''''''''''' 138 | 139 | Released on September 17, 2020. 140 | 141 | - Fixed a distribution bug that the :file:`wsgioauth2.py` file was missing. 142 | [:issue:`14`] 143 | 144 | 145 | Version 0.2.1 146 | ''''''''''''' 147 | 148 | Released on September 3, 2020. 149 | 150 | - Fixed a bug that :exc:`ImportError` has raised on Python 3.8. 151 | [:pull:`12` by Jörg Benesch] 152 | 153 | - Fixed a bug that :class:`~wsgioauth2.GitHubService` raises :exc:`KeyError` 154 | when users who didn't declare their name on GitHub tries to login. 155 | [:pull:`9` by Aymeric Augustin] 156 | 157 | 158 | Version 0.2.0 159 | ''''''''''''' 160 | 161 | Released on August 10, 2014. 162 | 163 | - Now it becomes compatible with Python 3.2 or later. 164 | 165 | 166 | Version 0.1.4 167 | ''''''''''''' 168 | 169 | Released on August 8, 2014. 170 | 171 | - :attr:`~wsgioauth2.WSGIMiddleware.login_path` can now be configured to 172 | protect only a subsection of an application. 173 | [:pull:`8` by Aymeric Augustin] 174 | 175 | 176 | Version 0.1.3 177 | ''''''''''''' 178 | 179 | Released on June 19, 2013. 180 | 181 | - :attr:`~wsgioauth2.WSGIMiddleware.forbidden_path` can now be configured. 182 | Default forbidden page is ugly so also allow forbidden path to be passed 183 | through to the protected application so it can be styled properly. 184 | [:pull:`4` by Mike Milner] 185 | - :class:`~wsgioauth2.GitHubService` now takes an optional parameter 186 | ``allowed_orgs`` to limit access based on GitHub organizations. 187 | [:pull:`4` by Mike Milner] 188 | - Fix :meth:`Client.request_access_token()` to understand 189 | :mimetype:`Content-Type` with ``charset``. 190 | [:pull:`5` by Jacob Kaplan-Moss] 191 | 192 | 193 | Version 0.1.2 194 | ''''''''''''' 195 | 196 | Released on March 22, 2013. 197 | 198 | - Add predefined GitHub service (:data:`wsgioauth2.github`). 199 | [:pull:`3` by Mike Milner] 200 | - Add option to set :envvar:`REMOTE_USER`. [:pull:`3` by Mike Milner] 201 | 202 | 203 | Version 0.1.1 204 | ''''''''''''' 205 | 206 | Released on May 2, 2012. 207 | 208 | - Set cookie with ``expires`` option if the response contains ``expires_in`` 209 | parameter. [:pull:`2` by mete0r] 210 | 211 | 212 | Version 0.1.0 213 | ''''''''''''' 214 | 215 | Released on November 4, 2011. First version. 216 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\wsgi-oauth2.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\wsgi-oauth2.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = wsgi-oauth2 3 | version = attr: wsgioauth2.__version__ 4 | description = Simple WSGI middleware for OAuth 2.0 5 | long-description = file: README.rst 6 | long-description-content-type = text/x-rst; charset=UTF-8 7 | author = Hong Minhee 8 | author_email = hong.minhee@gmail.com 9 | license = MIT license 10 | url = https://dahlia.github.io/wsgi-oauth2/ 11 | classifiers = 12 | Development Status :: 5 - Production/Stable 13 | Environment :: Web Environment 14 | Intended Audience :: Developers 15 | Intended Audience :: System Administrators 16 | License :: OSI Approved :: MIT License 17 | Programming Language :: Python :: 2.6 18 | Programming Language :: Python :: 2.7 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3.2 21 | Programming Language :: Python :: 3.3 22 | Programming Language :: Python :: 3.4 23 | Programming Language :: Python :: 3.5 24 | Programming Language :: Python :: 3.6 25 | Programming Language :: Python :: 3.7 26 | Programming Language :: Python :: 3.8 27 | Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware 28 | 29 | [options] 30 | python_requires = >=2.6, !=3.0.*, !=3.1.*, <4 31 | py-modules = 32 | wsgioauth2 33 | 34 | [aliases] 35 | upload_doc = build_sphinx upload_doc 36 | 37 | [bdist_wheel] 38 | universal = 1 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from setuptools import setup 3 | import distutils.cmd 4 | import os 5 | import os.path 6 | import re 7 | import shutil 8 | import tempfile 9 | 10 | 11 | class upload_doc(distutils.cmd.Command): 12 | """Uploads the documentation to GitHub pages.""" 13 | 14 | description = __doc__ 15 | user_options = [] 16 | 17 | def initialize_options(self): 18 | pass 19 | 20 | def finalize_options(self): 21 | pass 22 | 23 | def run(self): 24 | path = tempfile.mkdtemp() 25 | build = os.path.join(os.path.dirname(os.path.abspath(__file__)), 26 | 'build', 'sphinx', 'html') 27 | os.chdir(path) 28 | os.system('git clone git@github.com:dahlia/wsgi-oauth2.git .') 29 | os.system('git checkout gh-pages') 30 | os.system('git rm -r .') 31 | os.system('touch .nojekyll') 32 | os.system('cp -r ' + build + '/* .') 33 | os.system('git stage .') 34 | os.system('git commit -a -m "Documentation updated."') 35 | os.system('git push origin gh-pages') 36 | shutil.rmtree(path) 37 | 38 | 39 | setup(cmdclass={'upload_doc': upload_doc, 'upload_docs': upload_doc}) 40 | -------------------------------------------------------------------------------- /wsgioauth2.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2011-2020 by Hong Minhee 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | """:mod:`wsgioauth2` --- Simple WSGI middleware for OAuth 2.0 21 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 | 23 | This module provides a simple WSGI middleware that requires the user to 24 | authenticate via the specific `OAuth 2.0`_ service, e.g., Facebook_, Google_. 25 | 26 | .. _OAuth 2.0: http://oauth.net/2/ 27 | .. _Facebook: http://www.facebook.com/ 28 | .. _Google: http://www.google.com/ 29 | 30 | """ 31 | import base64 32 | import binascii 33 | try: 34 | from html import escape as html_escape 35 | except ImportError: 36 | from cgi import escape as html_escape 37 | try: 38 | import Cookie 39 | except ImportError: 40 | from http import cookies as Cookie 41 | import hashlib 42 | import hmac 43 | try: 44 | import simplejson as json 45 | except ImportError: 46 | import json 47 | import numbers 48 | try: 49 | import cPickle as pickle 50 | except ImportError: 51 | import pickle 52 | import random 53 | try: 54 | import urllib2 55 | except ImportError: 56 | from urllib import request as urllib2 57 | try: 58 | import urlparse 59 | except ImportError: 60 | from urllib import parse as urlparse 61 | urlencode = urlparse.urlencode 62 | else: 63 | from urllib import urlencode 64 | 65 | __author__ = 'Hong Minhee' # http://hongminhee.org/ 66 | __email__ = 'hong.minhee' "@" 'gmail.com' 67 | __license__ = 'MIT License' 68 | __version__ = '0.2.3' 69 | __copyright__ = '2011-2020, Hong Minhee' 70 | 71 | __all__ = ('AccessToken', 'Client', 'GitHubService', 'GithubService', 72 | 'Service', 'WSGIMiddleware', 'github', 'google', 'facebook') 73 | 74 | 75 | # Python 3 compatibility 76 | try: 77 | basestring 78 | except NameError: 79 | basestring = str 80 | 81 | 82 | class Service(object): 83 | """OAuth 2.0 service provider e.g. Facebook, Google. It takes 84 | endpoint urls for authorization and access token gathering APIs. 85 | 86 | :param authorize_endpoint: api url for authorization 87 | :type authorize_endpoint: :class:`basestring` 88 | :param access_token_endpoint: api url for getting access token 89 | :type access_token_endpoint: :class:`basestring` 90 | 91 | """ 92 | 93 | #: (:class:`basestring`) The API URL for authorization. 94 | authorize_endpoint = None 95 | 96 | #: (:class:`basestring`) The API URL for getting access token. 97 | access_token_endpoint = None 98 | 99 | def __init__(self, authorize_endpoint, access_token_endpoint): 100 | def check_endpoint(endpoint): 101 | if not isinstance(endpoint, basestring): 102 | raise TypeError('endpoint must be a string, not ' + 103 | repr(endpoint)) 104 | elif not (endpoint.startswith('http://') or 105 | endpoint.startswith('https://')): 106 | raise ValueError('endpoint must be a url string, not ' + 107 | repr(endpoint)) 108 | return endpoint 109 | self.authorize_endpoint = check_endpoint(authorize_endpoint) 110 | self.access_token_endpoint = check_endpoint(access_token_endpoint) 111 | 112 | def load_username(self, access_token): 113 | """Load a username from the service suitable for the REMOTE_USER 114 | variable. A valid :class:`AccessToken` is provided to allow access to 115 | authenticated resources provided by the service. If the service supports 116 | usernames this method must set the 'username' parameter to access_token. 117 | 118 | :param access_token: a valid :class:`AccessToken` 119 | 120 | .. versionadded:: 0.1.2 121 | 122 | """ 123 | raise NotImplementedError( 124 | "This Service does not provide a username for REMOTE_USER") 125 | 126 | def is_user_allowed(self, access_token): 127 | """Check if the authenticated user is allowed to access the protected 128 | application. By default, any authenticated user is allowed access. 129 | Override this check to allow the :class:`Service` to further-restrict 130 | access based on additional information known by the service. 131 | 132 | :param access_token: a valid :class:`AccessToken` 133 | 134 | .. versionadded:: 0.1.3 135 | 136 | """ 137 | return True 138 | 139 | def make_client(self, client_id, client_secret, **extra): 140 | """Makes a :class:`Client` for the service. 141 | 142 | :param client_id: a client id 143 | :type client_id: :class:`basestring`, :class:`numbers.Integral` 144 | :param client_secret: client secret key 145 | :type client_secret: :class:`basestring` 146 | :returns: a client for the service 147 | :rtype: :class:`Client` 148 | :param \*\*extra: additional arguments for authorization e.g. 149 | ``scope='email,read_stream'`` 150 | 151 | """ 152 | return Client(self, client_id, client_secret, **extra) 153 | 154 | 155 | class GitHubService(Service): 156 | """OAuth 2.0 service provider for GitHub with support for getting the 157 | authorized username. 158 | 159 | :param allowed_orgs: What GitHub Organizations are allowed to access the 160 | protected application. 161 | :type allowed_orgs: :class:`basestring`, 162 | :class:`collections.Container` of :class:`basestring` 163 | 164 | .. versionadded:: 0.1.3 165 | The ``allowed_orgs`` option. 166 | 167 | .. versionadded:: 0.1.2 168 | 169 | """ 170 | 171 | def __init__(self, allowed_orgs=None): 172 | super(GitHubService, self).__init__( 173 | authorize_endpoint='https://github.com/login/oauth/authorize', 174 | access_token_endpoint='https://github.com/login/oauth/access_token') 175 | # coerce a single string into a list 176 | if isinstance(allowed_orgs, basestring): 177 | allowed_orgs = [allowed_orgs] 178 | self.allowed_orgs = allowed_orgs 179 | 180 | def load_username(self, access_token): 181 | """Load a username from the service suitable for the REMOTE_USER 182 | variable. A valid :class:`AccessToken` is provided to allow access to 183 | authenticated resources provided by the service. For GitHub the 'login' 184 | variable is used. 185 | 186 | :param access_token: a valid :class:`AccessToken` 187 | 188 | .. versionadded:: 0.1.2 189 | 190 | """ 191 | response = access_token.get('https://api.github.com/user') 192 | response = response.read() 193 | response = json.loads(response) 194 | # Copy useful data 195 | access_token["username"] = response["login"] 196 | access_token["name"] = response.get("name", "") 197 | 198 | def is_user_allowed(self, access_token): 199 | """Check if the authenticated user is allowed to access the protected 200 | application. If this :class:`GitHubService` was created with a list of 201 | allowed_orgs, the user must be a memeber of one or more of the 202 | allowed_orgs to get access. If no allowed_orgs were specified, all 203 | authenticated users will be allowed. 204 | 205 | :param access_token: a valid :class:`AccessToken` 206 | 207 | .. versionadded:: 0.1.3 208 | 209 | """ 210 | # if there is no list of allowed organizations, any authenticated user 211 | # is allowed. 212 | if not self.allowed_orgs: 213 | return True 214 | 215 | # Get a list of organizations for the authenticated user 216 | response = access_token.get("https://api.github.com/user/orgs") 217 | response = response.read() 218 | response = json.loads(response) 219 | user_orgs = set(org["login"] for org in response) 220 | 221 | allowed_orgs = set(self.allowed_orgs) 222 | # If any orgs overlap, allow the user. 223 | return bool(allowed_orgs.intersection(user_orgs)) 224 | 225 | 226 | GithubService = GitHubService 227 | 228 | 229 | class Client(object): 230 | """Client for :class:`Service`. 231 | 232 | :param service: service the client connects to 233 | :type servie: :class:`Service` 234 | :param client_id: client id 235 | :type client_id: :class:`basestring`, :class:`numbers.Integral` 236 | :param client_secret: client secret key 237 | :type client_secret: :class:basestring` 238 | :param \*\*extra: additional arguments for authorization e.g. 239 | ``scope='email,read_stream'`` 240 | 241 | """ 242 | 243 | #: (:class:`Service`) The service the client connects to. 244 | service = None 245 | 246 | #: (:class:`basestring`) The client id. 247 | client_id = None 248 | 249 | #: (:class:`basestring`) The client secret key. 250 | client_secret = None 251 | 252 | #: (:class:`dict`) The additional arguments for authorization e.g. 253 | #: ``{'scope': 'email,read_stream'}``. 254 | 255 | def __init__(self, service, client_id, client_secret, **extra): 256 | if not isinstance(service, Service): 257 | raise TypeError('service must be a wsgioauth2.Service instance, ' 258 | 'not ' + repr(service)) 259 | elif isinstance(client_id, numbers.Integral): 260 | client_id = str(client_id) 261 | elif not isinstance(client_id, basestring): 262 | raise TypeError('client_id must be a string, not ' + 263 | repr(client_id)) 264 | elif not isinstance(client_secret, basestring): 265 | raise TypeError('client_secret must be a string, not ' + 266 | repr(client_secret)) 267 | self.service = service 268 | self.client_id = client_id 269 | self.client_secret = client_secret 270 | self.extra = extra 271 | 272 | def make_authorize_url(self, redirect_uri, state=None): 273 | """Makes an authorize URL. 274 | 275 | :param redirect_uri: callback url 276 | :type redirect_uri: :class:`basestring` 277 | :param state: optional state to get when the user returns to 278 | callback 279 | :type state: :class:`basestring` 280 | :returns: generated authorize url 281 | :rtype: :class:`basestring` 282 | 283 | """ 284 | query = dict(self.extra) 285 | query.update(client_id=self.client_id, 286 | redirect_uri=redirect_uri, 287 | response_type='code') 288 | if state is not None: 289 | query['state'] = state 290 | return '{0}?{1}'.format(self.service.authorize_endpoint, 291 | urlencode(query)) 292 | 293 | def load_username(self, access_token): 294 | """Load a username from the configured service suitable for the 295 | REMOTE_USER variable. A valid :class:`AccessToken` is provided to allow 296 | access to authenticated resources provided by the service. For GitHub 297 | the 'login' variable is used. 298 | 299 | :param access_token: a valid :class:`AccessToken` 300 | 301 | .. versionadded:: 0.1.2 302 | 303 | """ 304 | self.service.load_username(access_token) 305 | 306 | def is_user_allowed(self, access_token): 307 | return self.service.is_user_allowed(access_token) 308 | 309 | def request_access_token(self, redirect_uri, code): 310 | """Requests an access token. 311 | 312 | :param redirect_uri: ``redirect_uri`` that was passed to 313 | :meth:`make_authorize_url` 314 | :type redirect_uri: :class:`basestring` 315 | :param code: verification code that authorize endpoint provides 316 | :type code: :class:`code` 317 | :returns: access token and additional data 318 | :rtype: :class:`AccessToken` 319 | 320 | """ 321 | form = {'code': code, 322 | 'client_id': self.client_id, 323 | 'client_secret': self.client_secret, 324 | 'redirect_uri': redirect_uri, 325 | 'grant_type': 'authorization_code'} 326 | u = urllib2.urlopen(self.service.access_token_endpoint, 327 | data=urlencode(form).encode('utf-8')) 328 | m = u.info() 329 | try: 330 | # Python 2 331 | content_type = m.gettype() 332 | except AttributeError: 333 | # Python 3 334 | content_type = m.get_content_type() 335 | if content_type == 'application/json': 336 | data = json.load(u) 337 | else: 338 | data = dict( 339 | (k.decode('utf-8') 340 | if not isinstance(k, str) and isinstance(k, bytes) 341 | else k, v) 342 | for k, v in urlparse.parse_qs(u.read()).items() 343 | ) 344 | u.close() 345 | return AccessToken(data) 346 | 347 | def wsgi_middleware(self, *args, **kwargs): 348 | """Wraps a WSGI application.""" 349 | return WSGIMiddleware(self, *args, **kwargs) 350 | 351 | 352 | class AccessToken(dict): 353 | """Dictionary that contains access token. It always has ``'access_token'`` 354 | key. 355 | 356 | """ 357 | 358 | def __init__(self, *args, **kwargs): 359 | super(AccessToken, self).__init__(*args, **kwargs) 360 | if 'access_token' not in self: 361 | raise TypeError("'access_token' is required") 362 | 363 | @property 364 | def access_token(self): 365 | """(:class:`basestring`) Access token.""" 366 | access_token = self['access_token'] 367 | if isinstance(access_token, list): 368 | return access_token[0] 369 | return access_token 370 | 371 | def get(self, url, headers={}): 372 | """Requests ``url`` as ``GET``. 373 | 374 | :param headers: additional headers 375 | :type headers: :class:`collections.Mapping` 376 | 377 | """ 378 | url += ('&' if '?' in url else '?') + 'access_token=' + self.access_token 379 | request = urllib2.Request(url, headers=headers) 380 | return urllib2.urlopen(request) 381 | 382 | def post(self, url, form={}, headers={}): 383 | """Requests ``url`` as ``POST``. 384 | 385 | :param form: form data 386 | :type form: :class:`collections.Mapping` 387 | :param headers: additional headers 388 | :type headers: :class:`collections.Mapping` 389 | 390 | """ 391 | form = dict(form) 392 | form['access_token'] = self.access_token 393 | request = urllib2.Request(url, data=form, headers=headers) 394 | return urllib2.urlopen(request) 395 | 396 | def __str__(self): 397 | return self.access_token 398 | 399 | def __repr__(self): 400 | cls = type(self) 401 | repr_ = dict.__repr__(self) 402 | return '{0}.{1}({2})'.format(cls.__module__, cls.__name__, repr_) 403 | 404 | 405 | class WSGIMiddleware(object): 406 | """WSGI middleware application. 407 | 408 | :param client: oauth2 client 409 | :type client: :class:`Client` 410 | :param application: wsgi application 411 | :type application: callable object 412 | :param secret: secret key for generating HMAC signature 413 | :type secret: :class:`bytes` 414 | :param path: path prefix used for callback. by default, a randomly 415 | generated complex path is used 416 | :type path: :class:`basestring` 417 | :param cookie: cookie name to be used for maintaining the user session. 418 | default is :const:`DEFAULT_COOKIE` 419 | :type cookie: :class:`basestring` 420 | :param set_remote_user: Set to True to set the REMOTE_USER environment 421 | variable to the authenticated username (if supported 422 | by the :class:`Service`) 423 | :type set_remote_user: :class:`bool` 424 | :param forbidden_path: What path should be used to display the 403 Forbidden 425 | page. Any forbidden user will be redirected to this 426 | path and a default 403 Forbidden page will be shown. 427 | To override the default Forbidden page see the 428 | ``forbidden_passthrough`` option. 429 | :type forbidden_path: :class:`basestring` 430 | :param forbidden_passthrough: Should the forbidden page be passed-through to 431 | the protected application. By default, a 432 | generic Forbidden page will be generated. Set 433 | this to :const:`True` to pass the request 434 | through to the protected application. 435 | :type forbidden_passthrough: :class:`bool` 436 | :param login_path: The base path under which login will be required. Any 437 | URL starting with this path will trigger the OAuth2 438 | process. The default is '/', meaning that the entire 439 | application is protected. To override the default 440 | path see the :attr:`login_path` option. 441 | :type login_path: :class:`basestring` 442 | 443 | .. versionadded:: 0.1.4 444 | The ``login_path`` option. 445 | 446 | .. versionadded:: 0.1.3 447 | The ``forbidden_path`` and ``forbidden_passthrough`` options. 448 | 449 | .. versionadded:: 0.1.2 450 | The ``set_remote_user`` option. 451 | 452 | """ 453 | 454 | #: (:class:`basestring`) The default name for :attr:`cookie`. 455 | DEFAULT_COOKIE = 'wsgioauth2sess' 456 | 457 | #: (:class:`Client`) The OAuth2 client. 458 | client = None 459 | 460 | #: (callable object) The wrapped WSGI application. 461 | application = None 462 | 463 | #: (:class:`bytes`) The secret key for generating HMAC signature. 464 | secret = None 465 | 466 | #: (:class:`basestring`) The path prefix for callback URL. It always 467 | #: starts and ends with ``'/'``. 468 | path = None 469 | 470 | #: (:class:`basestring`) The path that is used to display the 403 Forbidden 471 | #: page. Any forbidden user will be redirected to this path and a default 472 | #: 403 Forbidden page will be shown. To override the default Forbidden 473 | #: page see the :attr:`forbidden_passthrough` option. 474 | forbidden_path = None 475 | 476 | #: (:class:`bool`) Whether the forbidden page should be passed-through 477 | #: to the protected application. By default, a generic Forbidden page 478 | #: will be generated. Set this to :const:`True` to pass the request 479 | #: through to the protected application. 480 | forbidden_passthrough = None 481 | 482 | #: (:class:`basestring`) The base path under which login will be required. 483 | #: Any URL starting with this path will trigger the OAuth2 process. The 484 | #: default is '/', meaning that the entire application is protected. To 485 | #: override the default path see the :attr:`login_path` option. 486 | #: 487 | #: .. versionadded:: 0.1.4 488 | login_path = None 489 | 490 | #: (:class:`basestring`) The cookie name to be used for maintaining 491 | #: the user session. 492 | cookie = None 493 | 494 | def __init__(self, client, application, secret, 495 | path=None, cookie=DEFAULT_COOKIE, set_remote_user=False, 496 | forbidden_path=None, forbidden_passthrough=False, 497 | login_path=None): 498 | if not isinstance(client, Client): 499 | raise TypeError('client must be a wsgioauth2.Client instance, ' 500 | 'not ' + repr(client)) 501 | if not callable(application): 502 | raise TypeError('application must be an WSGI compliant callable, ' 503 | 'not ' + repr(application)) 504 | if not isinstance(secret, bytes): 505 | raise TypeError('secret must be bytes, not ' + repr(secret)) 506 | if not (path is None or isinstance(path, basestring)): 507 | raise TypeError('path must be a string, not ' + repr(path)) 508 | if not (forbidden_path is None or 509 | isinstance(forbidden_path, basestring)): 510 | raise TypeError('forbidden_path must be a string, not ' + 511 | repr(path)) 512 | if not (login_path is None or 513 | isinstance(login_path, basestring)): 514 | raise TypeError('login_path must be a string, not ' + 515 | repr(path)) 516 | if not isinstance(cookie, basestring): 517 | raise TypeError('cookie must be a string, not ' + repr(cookie)) 518 | self.client = client 519 | self.application = application 520 | self.secret = secret 521 | if path is None: 522 | seq = ('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' 523 | 'abcdefghijklmnopqrstuvwxyz_-.') 524 | path = ''.join(random.choice(seq) for x in range(40)) 525 | path = '__{0}__'.format(path) 526 | self.path = '/{0}/'.format(path.strip('/')) 527 | if forbidden_path is None: 528 | forbidden_path = "/forbidden" 529 | # forbidden_path must start with a / to avoid relative links 530 | if not forbidden_path.startswith('/'): 531 | forbidden_path = '/' + forbidden_path 532 | self.forbidden_path = forbidden_path 533 | self.forbidden_passthrough = forbidden_passthrough 534 | if login_path is None: 535 | login_path = '/' 536 | # login_path must start with a / to ensure proper matching 537 | if not login_path.startswith('/'): 538 | login_path = '/' + login_path 539 | self.login_path = login_path 540 | self.cookie = cookie 541 | self.set_remote_user = set_remote_user 542 | 543 | def sign(self, value): 544 | """Generate signature of the given ``value``. 545 | 546 | .. versionadded:: 0.2.0 547 | 548 | """ 549 | if not isinstance(value, bytes): 550 | raise TypeError('expected bytes, not ' + repr(value)) 551 | return hmac.new(self.secret, value, hashlib.sha1).hexdigest() 552 | 553 | def redirect(self, url, start_response, headers={}): 554 | h = {'Content-Type': 'text/html; charset=utf-8', 'Location': url} 555 | h.update(headers) 556 | start_response('307 Temporary Redirect', list(h.items())) 557 | e_url = html_escape(url).encode('iso-8859-1') 558 | yield b'' 559 | yield b'' 560 | yield b'Redirect to ' 563 | yield e_url 564 | yield b'

Redirect to ' 567 | yield e_url 568 | yield b'

' 569 | 570 | def forbidden(self, start_response): 571 | """Respond with an HTTP 403 Forbidden status.""" 572 | h = [('Content-Type', 'text/html; charset=utf-8')] 573 | start_response('403 Forbidden', h) 574 | yield b'' 575 | yield b'' 576 | yield b'Forbidden' 577 | yield b'

403 Forbidden - ' 578 | yield b'Your account does not have access to the requested resource.' 579 | yield b'

'
580 |         yield b'
' 581 | yield b'

' 582 | 583 | def __call__(self, environ, start_response): 584 | url = '{0}://{1}{2}'.format(environ.get('wsgi.url_scheme', 'http'), 585 | environ.get('HTTP_HOST', ''), 586 | environ.get('PATH_INFO', '/')) 587 | redirect_uri = urlparse.urljoin(url, self.path) 588 | forbidden_uri = urlparse.urljoin(url, self.forbidden_path) 589 | query_string = environ.get('QUERY_STRING', '') 590 | if query_string: 591 | url += '?' + query_string 592 | cookie_dict = Cookie.SimpleCookie() 593 | cookie_dict.load(environ.get('HTTP_COOKIE', '')) 594 | query_dict = urlparse.parse_qs(query_string) 595 | path = environ['PATH_INFO'] 596 | if path.startswith(self.forbidden_path): 597 | if self.forbidden_passthrough: 598 | # Pass the forbidden request through to the app 599 | return self.application(environ, start_response) 600 | return self.forbidden(start_response) 601 | 602 | elif path.startswith(self.path): 603 | code = query_dict.get('code') 604 | if not code: 605 | # No code in URL - forbidden 606 | return self.redirect(forbidden_uri, start_response) 607 | 608 | try: 609 | code = code[0] 610 | access_token = self.client.request_access_token(redirect_uri, 611 | code) 612 | except TypeError: 613 | # No access token provided - forbidden 614 | return self.redirect(forbidden_uri, start_response) 615 | 616 | # Load the username now so it's in the session cookie 617 | if self.set_remote_user: 618 | self.client.load_username(access_token) 619 | 620 | # Check if the authenticated user is allowed 621 | if not self.client.is_user_allowed(access_token): 622 | return self.redirect(forbidden_uri, start_response) 623 | 624 | session = pickle.dumps(access_token) 625 | sig = self.sign(session) 626 | signed_session = sig.encode('ascii') + b',' + session 627 | signed_session = base64.urlsafe_b64encode(signed_session) 628 | set_cookie = Cookie.SimpleCookie() 629 | set_cookie[self.cookie] = signed_session.decode('ascii') 630 | set_cookie[self.cookie]['path'] = '/' 631 | if 'expires_in' in access_token: 632 | expires_in = int(access_token['expires_in']) 633 | set_cookie[self.cookie]['expires'] = expires_in 634 | set_cookie = set_cookie[self.cookie].OutputString() 635 | return self.redirect(query_dict.get('state', [''])[0], 636 | start_response, 637 | headers={'Set-Cookie': set_cookie}) 638 | elif path.startswith(self.login_path): 639 | if self.cookie in cookie_dict: 640 | session = cookie_dict[self.cookie].value 641 | try: 642 | session = base64.urlsafe_b64decode(session) 643 | except binascii.Error: 644 | session = b'' 645 | if b',' in session: 646 | sig, val = session.split(b',', 1) 647 | if sig.decode('ascii') == self.sign(val): 648 | try: 649 | session = pickle.loads(val) 650 | except (pickle.UnpicklingError, ValueError): 651 | session = None 652 | else: 653 | session = None 654 | else: 655 | session = None 656 | else: 657 | session = None 658 | 659 | if session is None: 660 | return self.redirect( 661 | self.client.make_authorize_url(redirect_uri, state=url), 662 | start_response 663 | ) 664 | else: 665 | environ = dict(environ) 666 | environ['wsgioauth2.session'] = session 667 | if self.set_remote_user and session['username']: 668 | environ['REMOTE_USER'] = session['username'] 669 | 670 | return self.application(environ, start_response) 671 | 672 | 673 | #: (:class:`Service`) The predefined service for Facebook__. 674 | #: 675 | #: __ https://www.facebook.com/ 676 | facebook = Service( 677 | authorize_endpoint='https://www.facebook.com/dialog/oauth', 678 | access_token_endpoint='https://graph.facebook.com/oauth/access_token' 679 | ) 680 | 681 | #: (:class:`Service`) The predefined service for Google__. 682 | #: 683 | #: __ http://www.google.com/ 684 | google = Service( 685 | authorize_endpoint='https://accounts.google.com/o/oauth2/auth', 686 | access_token_endpoint='https://accounts.google.com/o/oauth2/token' 687 | ) 688 | 689 | #: (:class:`GitHubService`) The predefined service for GitHub__. 690 | #: 691 | #: .. versionadded:: 0.1.2 692 | #: 693 | #: __ https://github.com/ 694 | github = GitHubService() 695 | --------------------------------------------------------------------------------