├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── Vagrantfile ├── docs ├── Makefile ├── api.rst ├── changes.rst ├── conf.py ├── getting_started.rst └── index.rst ├── manage.py ├── provider ├── __init__.py ├── compat │ └── __init__.py ├── constants.py ├── forms.py ├── models.py ├── oauth2 │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── backends.py │ ├── fixtures │ │ └── test_oauth2.json │ ├── forms.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_fix_scope_size.py │ │ ├── 0003_public_client_options.py │ │ └── __init__.py │ ├── mixins.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_middleware.py │ │ ├── test_views.py │ │ └── urls.py │ ├── urls.py │ └── views.py ├── scope.py ├── sphinx.py ├── templates │ └── provider │ │ └── authorize.html ├── templatetags │ ├── __init__.py │ └── scope.py ├── tests │ ├── __init__.py │ └── test_utils.py ├── urls.py ├── utils.py └── views.py ├── requirements.txt ├── setup.py ├── test.sh ├── tests ├── __init__.py ├── settings.py ├── templates │ └── base.html └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | _build 4 | .*.sw[po] 5 | *.egg-info 6 | dist 7 | build 8 | venv 9 | .idea 10 | *.sqlite 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: focal 2 | sudo: false 3 | language: python 4 | python: 5 | - "3.8" 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Caffeinehit Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include provider/templates *.html 4 | recursive-include provider/templates *.txt 5 | recursive-include provider *json 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-oauth2 2 | ====================== 3 | 4 | .. image:: https://travis-ci.org/stormsherpa/django-oauth2-provider.png?branch=master 5 | 6 | *django-oauth2* is a Django application that provides 7 | customizable OAuth2\-authentication for your Django projects. 8 | 9 | `Documentation `_ 10 | 11 | License 12 | ======= 13 | 14 | *django-oauth2* is a fork of *django-oauth2-provider* which is released under the MIT License. Please see the LICENSE file for details. 15 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # All Vagrant configuration is done below. The "2" in Vagrant.configure 5 | # configures the configuration version (we support older styles for 6 | # backwards compatibility). Please don't change it unless you know what 7 | # you're doing. 8 | Vagrant.configure(2) do |config| 9 | # The most common configuration options are documented and commented below. 10 | # For a complete reference, please see the online documentation at 11 | # https://docs.vagrantup.com. 12 | 13 | # Every Vagrant development environment requires a box. You can search for 14 | # boxes at https://atlas.hashicorp.com/search. 15 | config.vm.box = "ubuntu/focal64" 16 | 17 | # Disable automatic box update checking. If you disable this, then 18 | # boxes will only be checked for updates when the user runs 19 | # `vagrant box outdated`. This is not recommended. 20 | # config.vm.box_check_update = false 21 | 22 | # Create a forwarded port mapping which allows access to a specific port 23 | # within the machine from a port on the host machine. In the example below, 24 | # accessing "localhost:8080" will access port 80 on the guest machine. 25 | # config.vm.network "forwarded_port", guest: 80, host: 8080 26 | # config.vm.network "forwarded_port", guest: 8000, host: 8000 27 | 28 | # Create a private network, which allows host-only access to the machine 29 | # using a specific IP. 30 | # config.vm.network "private_network", ip: "192.168.33.10" 31 | 32 | # Create a public network, which generally matched to bridged network. 33 | # Bridged networks make the machine appear as another physical device on 34 | # your network. 35 | # config.vm.network "public_network" 36 | 37 | # Share an additional folder to the guest VM. The first argument is 38 | # the path on the host to the actual folder. The second argument is 39 | # the path on the guest to mount the folder. And the optional third 40 | # argument is a set of non-required options. 41 | # config.vm.synced_folder "../data", "/vagrant_data" 42 | 43 | # Provider-specific configuration so you can fine-tune various 44 | # backing providers for Vagrant. These expose provider-specific options. 45 | # Example for VirtualBox: 46 | # 47 | config.vm.provider "virtualbox" do |vb| 48 | # Display the VirtualBox GUI when booting the machine 49 | # vb.gui = true 50 | # Customize the amount of memory on the VM: 51 | vb.memory = "2048" 52 | end 53 | # 54 | # View the documentation for the provider you are using for more 55 | # information on available options. 56 | 57 | # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies 58 | # such as FTP and Heroku are also available. See the documentation at 59 | # https://docs.vagrantup.com/v2/push/atlas.html for more information. 60 | # config.push.define "atlas" do |push| 61 | # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME" 62 | # end 63 | 64 | # Enable provisioning with a shell script. Additional provisioners such as 65 | # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the 66 | # documentation for more information about their specific syntax and use. 67 | config.vm.provision "shell", privileged: false, inline: <<-SHELL 68 | sudo resize2fs /dev/sda1 69 | sudo apt-get update 70 | sudo apt-get install -y build-essential python3-dev python3-pip python3-dev python3 python3-virtualenv virtualenv virtualenvwrapper postgresql libpq-dev memcached redis-server redis-tools 71 | 72 | sudo -H pip3 install tox==3.28.0 virtualenv 73 | 74 | echo "export TOX_WORK_DIR=/tmp/" >> ~/.bash_aliases 75 | 76 | SHELL 77 | end 78 | -------------------------------------------------------------------------------- /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 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-oauth2-provider.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-oauth2-provider.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-oauth2-provider" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-oauth2-provider" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | `provider` 3 | ========== 4 | 5 | `provider.constants` 6 | -------------------- 7 | .. automodule:: provider.constants 8 | :members: 9 | :no-undoc-members: 10 | 11 | .. currentmodule:: provider.constants 12 | 13 | .. attribute:: RESPONSE_TYPE_CHOICES 14 | 15 | :settings: `OAUTH_RESPONSE_TYPE_CHOICES` 16 | 17 | The response types as outlined by :rfc:`3.1.1` 18 | 19 | .. attribute:: SCOPES 20 | 21 | :settings: `OAUTH_SCOPES` 22 | 23 | A choice of scopes. A detailed implementation is left to the developer. 24 | The current default implementation in :attr:`provider.oauth2.scope` makes 25 | use of bit shifting operations to combine read and write permissions. 26 | 27 | .. attribute:: EXPIRE_DELTA 28 | 29 | :settings: `OAUTH_EXPIRE_DELTA` 30 | :default: `datetime.timedelta(days=365)` 31 | 32 | The time to expiry for access tokens as outlined in :rfc:`4.2.2` and 33 | :rfc:`5.1`. 34 | 35 | .. attribute:: EXPIRE_CODE_DELTA 36 | 37 | :settings: `OAUTH_EXPIRE_CODE_DELTA` 38 | :default: `datetime.timedelta(seconds=10*60)` 39 | 40 | The time to expiry for an authorization code grant as outlined in :rfc:`4.1.2`. 41 | 42 | .. attribute:: DELETE_EXPIRED 43 | 44 | :settings: `OAUTH_DELETE_EXPIRED` 45 | :default: `False` 46 | 47 | To remove expired tokens immediately instead of letting them persist, set 48 | to `True`. 49 | 50 | .. attribute:: ENFORCE_SECURE 51 | 52 | :settings: `OAUTH_ENFORCE_SECURE` 53 | :default: `False` 54 | 55 | To enforce secure communication on application level, set to `True`. 56 | 57 | .. attribute:: SESSION_KEY 58 | 59 | :settings: `OAUTH_SESSION_KEY` 60 | :default: `"oauth"` 61 | 62 | Session key prefix to store temporary data while the user is completing 63 | the authentication / authorization process. 64 | 65 | `provider.forms` 66 | ---------------- 67 | .. automodule:: provider.forms 68 | :members: 69 | :no-undoc-members: 70 | 71 | `provider.scope` 72 | ----------------------- 73 | .. automodule:: provider.scope 74 | :members: 75 | :no-undoc-members: 76 | 77 | `provider.templatetags.scope` 78 | ----------------------------- 79 | .. automodule:: provider.templatetags.scope 80 | :members: 81 | :no-undoc-members: 82 | 83 | `provider.utils` 84 | ---------------- 85 | .. automodule:: provider.utils 86 | :members: 87 | :no-undoc-members: 88 | 89 | `provider.views` 90 | ---------------- 91 | .. automodule:: provider.views 92 | :members: 93 | :no-undoc-members: 94 | 95 | 96 | `provider.oauth2` 97 | ================= 98 | 99 | `provider.oauth2.forms` 100 | ----------------------- 101 | .. automodule:: provider.oauth2.forms 102 | :members: 103 | :no-undoc-members: 104 | 105 | `provider.oauth2.models` 106 | ------------------------ 107 | .. automodule:: provider.oauth2.models 108 | :members: 109 | :no-undoc-members: 110 | 111 | `provider.oauth2.urls` 112 | ---------------------- 113 | .. automodule:: provider.oauth2.urls 114 | :members: 115 | :no-undoc-members: 116 | 117 | `provider.oauth2.views` 118 | ----------------------- 119 | .. automodule:: provider.oauth2.views 120 | :members: 121 | :no-undoc-members: 122 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | v 2.4 2 | ----- 3 | * Add HTTP Authorization Bearer token support to Oauth2UserMiddleware 4 | 5 | v 2.3 6 | ----- 7 | * Increase oauth2 scope name field size 8 | 9 | v 2.2 10 | ----- 11 | * Improve Oauth2UserMiddleware 12 | * Prevent SessionMiddleware from creating new sessions when using oauth tokens. 13 | * Add OAuthRequiredMixin to allow scope enforcement 14 | 15 | v 2.1 16 | ----- 17 | * Fixed documentation links. Removed 2.0 package. 18 | 19 | v 2.0 20 | ----- 21 | * Update for current Django 1.11, 2.0, and 2.1. 22 | 23 | v 1.2 24 | ----- 25 | Updated to make scopes configurable in the database and update for Django 1.7 26 | 27 | v 1.0 28 | ----- 29 | Forked from original project at caffeinehit/django-oauth2-provider 30 | 31 | v 0.2 32 | ----- 33 | * *Breaking change* Moved ``provider.oauth2.scope`` to ``provider.scope`` 34 | * *Breaking change* Replaced the write scope with a new write scope that includes reading 35 | * Default scope for new ``provider.oauth2.models.AccessToken`` is now ``provider.constants.SCOPES[0][0]`` 36 | * Access token response returns a space seperated list of scopes instead of an integer value 37 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-oauth2-provider documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jan 24 15:40:05 2012. 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 | sys.path = [(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))] + sys.path 17 | 18 | import provider 19 | 20 | 21 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 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 | # -- General configuration ----------------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | #needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be extensions 34 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'provider.sphinx'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'django-oauth2' 51 | copyright = u'2012, Alen Mujezinovic' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = provider.__version__ 59 | # The full version, including alpha/beta/rc tags. 60 | release = provider.__version__ 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all documents. 77 | #default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | #add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | #add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | #show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | #modindex_common_prefix = [] 95 | 96 | 97 | # -- Options for HTML output --------------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'default' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | #html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | #html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | #html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | #html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | #html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | #html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | #html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | #html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | #html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | #html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | #html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | #html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | #html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | #html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | #html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | #html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | #html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'django-oauth2doc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | # The paper size ('letter' or 'a4'). 180 | #latex_paper_size = 'letter' 181 | 182 | # The font size ('10pt', '11pt' or '12pt'). 183 | #latex_font_size = '10pt' 184 | 185 | # Grouping the document tree into LaTeX files. List of tuples 186 | # (source start file, target name, title, author, documentclass [howto/manual]). 187 | latex_documents = [ 188 | ('index', 'django-oauth2.tex', u'django-oauth2 Documentation', 189 | u'Alen Mujezinovic', 'manual'), 190 | ] 191 | 192 | # The name of an image file (relative to this directory) to place at the top of 193 | # the title page. 194 | #latex_logo = None 195 | 196 | # For "manual" documents, if this is true, then toplevel headings are parts, 197 | # not chapters. 198 | #latex_use_parts = False 199 | 200 | # If true, show page references after internal links. 201 | #latex_show_pagerefs = False 202 | 203 | # If true, show URL addresses after external links. 204 | #latex_show_urls = False 205 | 206 | # Additional stuff for the LaTeX preamble. 207 | #latex_preamble = '' 208 | 209 | # Documents to append as an appendix to all manuals. 210 | #latex_appendices = [] 211 | 212 | # If false, no module index is generated. 213 | #latex_domain_indices = True 214 | 215 | 216 | # -- Options for manual page output -------------------------------------------- 217 | 218 | # One entry per manual page. List of tuples 219 | # (source start file, name, description, authors, manual section). 220 | man_pages = [ 221 | ('index', 'django-oauth2', u'django-oauth2 Documentation', 222 | [u'Alen Mujezinovic'], 1) 223 | ] 224 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installation 5 | ############ 6 | 7 | .. sourcecode:: sh 8 | 9 | $ pip install django-oauth2 10 | 11 | Configuration 12 | ############# 13 | 14 | Add OAuth2 Provider to :attr:`INSTALLED_APPS` 15 | --------------------------------------------- 16 | 17 | :: 18 | 19 | INSTALLED_APPS = ( 20 | # ... 21 | 'provider', 22 | 'provider.oauth2', 23 | ) 24 | 25 | Modify your settings to match your needs 26 | ---------------------------------------- 27 | 28 | The default settings are available in :attr:`provider.constants`. 29 | 30 | 31 | Include the OAuth 2 views 32 | ------------------------- 33 | 34 | Add :attr:`provider.oauth2.urls` to your root ``urls.py`` file. 35 | 36 | :: 37 | 38 | url(r'^oauth2/', include('provider.oauth2.urls', namespace = 'oauth2')), 39 | 40 | 41 | .. note:: The namespace argument is required. 42 | 43 | Sync your database 44 | ------------------ 45 | 46 | .. sourcecode:: sh 47 | 48 | $ python manage.py syncdb 49 | $ python manage.py migrate 50 | 51 | How to request an :attr:`access token` for the first time ? 52 | ########################################################### 53 | 54 | Create a :attr:`client` entry in your database 55 | ---------------------------------------------- 56 | 57 | .. note:: To find out which type of :attr:`client` you need to create, read :rfc:`2.1`. 58 | 59 | To create a new entry simply use the Django admin panel. 60 | 61 | Request an access token 62 | ----------------------- 63 | 64 | Assuming that you've used the same URL configuration as above, your 65 | client needs to submit a :attr:`POST` request to 66 | :attr:`/oauth2/access_token` including the following parameters: 67 | 68 | * ``client_id`` - The client ID you've configured in the Django admin. 69 | * ``client_secret`` - The client secret configured in the Django admin. 70 | * ``username`` - The username with which you want to log in. 71 | * ``password`` - The password corresponding to the user you're logging 72 | in with. 73 | 74 | 75 | **Request** 76 | 77 | .. sourcecode:: sh 78 | 79 | $ curl -X POST -d "client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=password&username=YOUR_USERNAME&password=YOUR_PASSWORD" http://localhost:8000/oauth2/access_token/ 80 | 81 | **Response** 82 | 83 | .. sourcecode:: json 84 | 85 | {"access_token": "", "scope": "read", "expires_in": 86399, "refresh_token": ""} 86 | 87 | 88 | This particular way of obtaining an access token is called a **Password 89 | Grant**. All the other ways of acquiring an access token are outlined 90 | in :rfc:`4`. 91 | 92 | .. note:: Remember that you should always use HTTPS for all your OAuth 93 | 2 requests otherwise you won't be secured. 94 | 95 | Integrate with Django Authentication 96 | #################################### 97 | 98 | Add OAuth2 Middleware to :attr:`MIDDLEWARE_CLASSES` 99 | --------------------------------------------------- 100 | 101 | :: 102 | 103 | MIDDLEWARE_CLASSES = ( 104 | ... 105 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 106 | 'provider.oauth2.middleware.Oauth2UserMiddleware', 107 | ... 108 | ) 109 | 110 | Add RemoteUserBackend to :attr:`AUTHENTICATION_BACKENDS` 111 | -------------------------------------------------------- 112 | 113 | :: 114 | 115 | AUTHENTICATION_BACKENDS = ( 116 | 'django.contrib.auth.backends.ModelBackend', 117 | 'django.contrib.auth.backends.RemoteUserBackend', 118 | ) 119 | 120 | 121 | .. note:: The Oauth2UserMiddleware class reuses functionality used by the 122 | RemoteUserMiddleware class. Omitting the RemoteUserBackend 123 | will result in 500 errors. 124 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-oauth2's documentation! 2 | ================================================== 3 | 4 | *django-oauth2* is a Django application that provides customizable OAuth2_ authentication for your Django projects. 5 | 6 | The default implementation makes reasonable assumptions about the allowed grant types and provides clients with two easy accessible URL endpoints. (:attr:`provider.oauth2.urls`) 7 | 8 | If you require custom database backends, URLs, wish to extend the OAuth2_ protocol as defined in :rfc:`8` or anything else, you can override the default behaviours by subclassing the views in :attr:`provider.views` and add your specific use cases. 9 | 10 | Getting started 11 | ############### 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | getting_started 17 | 18 | API 19 | ### 20 | 21 | .. toctree:: 22 | :maxdepth: 4 23 | 24 | api 25 | 26 | Changes 27 | ####### 28 | 29 | .. toctree:: 30 | :maxdepth: 3 31 | 32 | changes 33 | 34 | 35 | Made by `Caffeinehit `_. 36 | 37 | .. _OAuth2: http://tools.ietf.org/html/rfc6749 38 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /provider/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.2" 2 | -------------------------------------------------------------------------------- /provider/compat/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | user_model_label = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 5 | 6 | 7 | try: 8 | from django.contrib.auth.tests.utils import skipIfCustomUser 9 | except ImportError: 10 | def skipIfCustomUser(wrapped): 11 | return wrapped 12 | -------------------------------------------------------------------------------- /provider/constants.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.conf import settings 3 | 4 | CONFIDENTIAL = 0 5 | PUBLIC = 1 6 | 7 | CLIENT_TYPES = ( 8 | (CONFIDENTIAL, "Confidential (Web applications)"), 9 | (PUBLIC, "Public (Native and JS applications)") 10 | ) 11 | 12 | RESPONSE_TYPE_CHOICES = getattr(settings, 'OAUTH_RESPONSE_TYPE_CHOICES', ("code", "token")) 13 | 14 | TOKEN_TYPE = 'Bearer' 15 | 16 | READ = 1 << 1 17 | WRITE = 1 << 2 18 | READ_WRITE = READ | WRITE 19 | 20 | DEFAULT_SCOPES = ( 21 | (READ, 'read'), 22 | (WRITE, 'write'), 23 | (READ_WRITE, 'read+write'), 24 | ) 25 | 26 | SCOPES = getattr(settings, 'OAUTH_SCOPES', DEFAULT_SCOPES) 27 | 28 | EXPIRE_DELTA = getattr(settings, 'OAUTH_EXPIRE_DELTA', timedelta(days=7)) 29 | 30 | # Expiry delta for public clients (which typically have shorter lived tokens) 31 | EXPIRE_DELTA_PUBLIC = getattr(settings, 'OAUTH_EXPIRE_DELTA_PUBLIC', timedelta(days=1)) 32 | 33 | EXPIRE_CODE_DELTA = getattr(settings, 'OAUTH_EXPIRE_CODE_DELTA', timedelta(seconds=10 * 60)) 34 | 35 | # Remove expired tokens immediately instead of letting them persist. 36 | DELETE_EXPIRED = getattr(settings, 'OAUTH_DELETE_EXPIRED', False) 37 | 38 | ENFORCE_SECURE = getattr(settings, 'OAUTH_ENFORCE_SECURE', False) 39 | ENFORCE_CLIENT_SECURE = getattr(settings, 'OAUTH_ENFORCE_CLIENT_SECURE', True) 40 | 41 | SESSION_KEY = getattr(settings, 'OAUTH_SESSION_KEY', 'oauth') 42 | 43 | -------------------------------------------------------------------------------- /provider/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class OAuthValidationError(Exception): 5 | """ 6 | Exception to throw inside :class:`OAuthForm` if any OAuth2 related errors 7 | are encountered such as invalid grant type, invalid client, etc. 8 | 9 | :attr:`OAuthValidationError` expects a dictionary outlining the OAuth error 10 | as its first argument when instantiating. 11 | 12 | :example: 13 | 14 | :: 15 | 16 | class GrantValidationForm(OAuthForm): 17 | grant_type = forms.CharField() 18 | 19 | def clean_grant(self): 20 | if not self.cleaned_data.get('grant_type') == 'code': 21 | raise OAuthValidationError({ 22 | 'error': 'invalid_grant', 23 | 'error_description': "%s is not a valid grant type" % ( 24 | self.cleaned_data.get('grant_type')) 25 | }) 26 | 27 | The different types of errors are outlined in :rfc:`4.2.2.1` and 28 | :rfc:`5.2`. 29 | """ 30 | 31 | 32 | class OAuthForm(forms.Form): 33 | """ 34 | Form class that creates shallow error dicts and exists early when a 35 | :class:`OAuthValidationError` is raised. 36 | 37 | The shallow error dict is reused when returning error responses to the 38 | client. 39 | 40 | The different types of errors are outlined in :rfc:`4.2.2.1` and 41 | :rfc:`5.2`. 42 | """ 43 | def __init__(self, *args, **kwargs): 44 | self.client = kwargs.pop('client', None) 45 | super(OAuthForm, self).__init__(*args, **kwargs) 46 | 47 | def _clean_fields(self): 48 | """ 49 | Overriding the default cleaning behaviour to exit early on errors 50 | instead of validating each field. 51 | """ 52 | try: 53 | super(OAuthForm, self)._clean_fields() 54 | except OAuthValidationError as e: 55 | self._errors.update(e.args[0]) 56 | 57 | def _clean_form(self): 58 | """ 59 | Overriding the default cleaning behaviour for a shallow error dict. 60 | """ 61 | try: 62 | super(OAuthForm, self)._clean_form() 63 | except OAuthValidationError as e: 64 | self._errors.update(e.args[0]) 65 | -------------------------------------------------------------------------------- /provider/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /provider/oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'provider.oauth2.apps.Oauth2' 2 | -------------------------------------------------------------------------------- /provider/oauth2/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from provider.oauth2 import models 3 | 4 | 5 | class AccessTokenAdmin(admin.ModelAdmin): 6 | list_display = ('user', 'client', 'token', 'expires',) 7 | raw_id_fields = ('user',) 8 | 9 | 10 | class GrantAdmin(admin.ModelAdmin): 11 | list_display = ('user', 'client', 'code', 'expires',) 12 | raw_id_fields = ('user',) 13 | 14 | 15 | class ClientAdmin(admin.ModelAdmin): 16 | list_display = ('url', 'user', 'redirect_uri', 'client_id', 17 | 'client_type', 'auto_authorize') 18 | raw_id_fields = ('user',) 19 | 20 | 21 | class AuthorizedClientAdmin(admin.ModelAdmin): 22 | list_display = ('user', 'client', 'authorized_at') 23 | raw_id_fields = ('user',) 24 | 25 | 26 | admin.site.register(models.AccessToken, AccessTokenAdmin) 27 | admin.site.register(models.Grant, GrantAdmin) 28 | admin.site.register(models.Client, ClientAdmin) 29 | admin.site.register(models.AuthorizedClient, AuthorizedClientAdmin) 30 | admin.site.register(models.RefreshToken) 31 | admin.site.register(models.Scope) 32 | -------------------------------------------------------------------------------- /provider/oauth2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class Oauth2(AppConfig): 4 | name = 'provider.oauth2' 5 | label = 'oauth2' 6 | verbose_name = "Provider Oauth2" 7 | -------------------------------------------------------------------------------- /provider/oauth2/backends.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from provider.utils import now 4 | from provider.oauth2.forms import ClientAuthForm, PublicPasswordGrantForm, PublicClientForm 5 | from provider.oauth2.models import AccessToken 6 | 7 | 8 | class BaseBackend(object): 9 | """ 10 | Base backend used to authenticate clients as defined in :rfc:`1` against 11 | our database. 12 | """ 13 | def authenticate(self, request=None): 14 | """ 15 | Override this method to implement your own authentication backend. 16 | Return a client or ``None`` in case of failure. 17 | """ 18 | pass 19 | 20 | 21 | class BasicClientBackend(object): 22 | """ 23 | Backend that tries to authenticate a client through HTTP authorization 24 | headers as defined in :rfc:`2.3.1`. 25 | """ 26 | def authenticate(self, request=None): 27 | auth = request.META.get('HTTP_AUTHORIZATION') 28 | 29 | if auth is None or auth == '': 30 | return None 31 | 32 | try: 33 | basic, enc_user_passwd = auth.split(' ') 34 | user_pass = base64.b64decode(enc_user_passwd).decode('utf8') 35 | client_id, client_secret = user_pass.split(':') 36 | 37 | form = ClientAuthForm({ 38 | 'client_id': client_id, 39 | 'client_secret': client_secret}) 40 | 41 | if form.is_valid(): 42 | return form.cleaned_data.get('client') 43 | return None 44 | 45 | except ValueError: 46 | # Auth header was malformed, unpacking went wrong 47 | return None 48 | 49 | 50 | class RequestParamsClientBackend(object): 51 | """ 52 | Backend that tries to authenticate a client through request parameters 53 | which might be in the request body or URI as defined in :rfc:`2.3.1`. 54 | """ 55 | def authenticate(self, request=None): 56 | if request is None: 57 | return None 58 | 59 | if hasattr(request, 'REQUEST'): 60 | args = request.REQUEST 61 | else: 62 | args = request.POST or request.GET 63 | form = ClientAuthForm(args) 64 | 65 | if form.is_valid(): 66 | return form.cleaned_data.get('client') 67 | 68 | return None 69 | 70 | 71 | class PublicPasswordBackend(object): 72 | """ 73 | Backend that tries to authenticate a client using username, password 74 | and client ID. This is only available in specific circumstances: 75 | 76 | - grant_type is "password" 77 | - client.client_type is 'public' 78 | """ 79 | 80 | def authenticate(self, request=None): 81 | if request is None: 82 | return None 83 | 84 | if hasattr(request, 'REQUEST'): 85 | args = request.REQUEST 86 | else: 87 | args = request.POST or request.GET 88 | form = PublicPasswordGrantForm(args) 89 | 90 | if form.is_valid(): 91 | return form.cleaned_data.get('client') 92 | 93 | return None 94 | 95 | 96 | class PublicClientBackend(object): 97 | def authenticate(self, request=None): 98 | if request is None: 99 | return None 100 | 101 | if hasattr(request, 'REQUEST'): 102 | args = request.REQUEST 103 | else: 104 | args = request.POST or request.GET 105 | form = PublicClientForm(args) 106 | 107 | if form.is_valid(): 108 | return form.cleaned_data.get('client') 109 | 110 | return None 111 | 112 | 113 | class AccessTokenBackend(object): 114 | """ 115 | Authenticate a user via access token and client object. 116 | """ 117 | 118 | def authenticate(self, access_token=None, client=None): 119 | try: 120 | return AccessToken.objects.get(token=access_token, 121 | expires__gt=now(), client=client) 122 | except AccessToken.DoesNotExist: 123 | return None 124 | -------------------------------------------------------------------------------- /provider/oauth2/fixtures/test_oauth2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "redirect_uri": "http://example.com/application/1/", 5 | "client_id": "90a4a24ffefe7ebbae2c", 6 | "client_secret": "35c25066023f32c4f098d1e40de94f07f98c1acf", 7 | "client_type": 0, 8 | "url": "http://example.com/", 9 | "user": 1 10 | }, 11 | "model": "oauth2.client", 12 | "pk": 1 13 | }, 14 | { 15 | "fields": { 16 | "redirect_uri": "http://example.com/application/2/", 17 | "client_id": "71fbc29950ac1b386a12", 18 | "client_secret": "1944b695ca0cbf4f419a7d5c7e4fed13a660bc04", 19 | "client_type": 0, 20 | "url": "http://example.com/", 21 | "user": 2 22 | }, 23 | "model": "oauth2.client", 24 | "pk": 2 25 | }, 26 | { 27 | "fields": { 28 | "redirect_uri": "http://example.com/application/3/", 29 | "client_id": "185f88c440a24e561edf", 30 | "client_secret": "717e8326751f05f7f1f9354c495152c8d7c7a0d4", 31 | "client_type": 1, 32 | "url": "http://example.com/", 33 | "user": 2, 34 | "allow_public_token": true, 35 | "authorize_every_time": true 36 | }, 37 | "model": "oauth2.client", 38 | "pk": 3 39 | }, 40 | { 41 | "fields": { 42 | "date_joined": "2012-01-23 05:44:17", 43 | "email": "test-1@example.com", 44 | "first_name": "", 45 | "groups": [], 46 | "is_active": true, 47 | "is_staff": true, 48 | "is_superuser": true, 49 | "last_login": "2012-01-23 05:52:32", 50 | "last_name": "", 51 | "password": "sha1$da29e$498b9faab2d002183bc1d874689634b0e15ad6d7", 52 | "user_permissions": [], 53 | "username": "test-user-1" 54 | }, 55 | "model": "auth.user", 56 | "pk": 1 57 | }, 58 | { 59 | "fields": { 60 | "date_joined": "2012-01-23 05:53:31", 61 | "email": "", 62 | "first_name": "", 63 | "groups": [], 64 | "is_active": true, 65 | "is_staff": false, 66 | "is_superuser": false, 67 | "last_login": "2012-01-23 05:53:31", 68 | "last_name": "", 69 | "password": "sha1$0cf1b$d66589690edd96b410170fcae5cc2bdfb68821e7", 70 | "user_permissions": [], 71 | "username": "test-user-2" 72 | }, 73 | "model": "auth.user", 74 | "pk": 2 75 | }, 76 | { 77 | "fields": { 78 | "name": "basic", 79 | "description": "A basic scope" 80 | }, 81 | "model": "oauth2.scope", 82 | "pk": 1 83 | }, 84 | { 85 | "fields": { 86 | "name": "advanced", 87 | "description": "An advanced scope" 88 | }, 89 | "model": "oauth2.scope", 90 | "pk": 2 91 | } 92 | ] 93 | -------------------------------------------------------------------------------- /provider/oauth2/forms.py: -------------------------------------------------------------------------------- 1 | from six import string_types 2 | from django import forms 3 | from django.contrib.auth import authenticate 4 | from django.conf import settings 5 | from django.utils.translation import ugettext as _ 6 | from django.utils import timezone 7 | from provider.constants import RESPONSE_TYPE_CHOICES, SCOPES, PUBLIC 8 | from provider.forms import OAuthForm, OAuthValidationError 9 | from provider.utils import now 10 | from provider.oauth2.models import Client, Grant, RefreshToken, Scope 11 | 12 | 13 | DEFAULT_SCOPE = getattr(settings, 'OAUTH2_DEFAULT_SCOPE', 'read') 14 | 15 | 16 | class ClientForm(forms.ModelForm): 17 | """ 18 | Form to create new consumers. 19 | """ 20 | class Meta: 21 | model = Client 22 | fields = ('name', 'url', 'redirect_uri', 'client_type') 23 | 24 | def save(self, user=None, **kwargs): 25 | self.instance.user = user 26 | return super(ClientForm, self).save(**kwargs) 27 | 28 | 29 | class ClientAuthForm(forms.Form): 30 | """ 31 | Client authentication form. Required to make sure that we're dealing with a 32 | real client. Form is used in :attr:`provider.oauth2.backends` to validate 33 | the client. 34 | """ 35 | client_id = forms.CharField() 36 | client_secret = forms.CharField() 37 | 38 | def clean(self): 39 | data = self.cleaned_data 40 | try: 41 | client = Client.objects.get(client_id=data.get('client_id'), 42 | client_secret=data.get('client_secret')) 43 | except Client.DoesNotExist: 44 | raise forms.ValidationError(_("Client could not be validated with " 45 | "key pair.")) 46 | 47 | data['client'] = client 48 | return data 49 | 50 | 51 | class ScopeModelChoiceField(forms.ModelMultipleChoiceField): 52 | 53 | # widget = forms.TextInput 54 | 55 | def to_python(self, value): 56 | if isinstance(value, string_types): 57 | return [s for s in value.split(' ') if s != ''] 58 | elif isinstance(value, list): 59 | value_list = list() 60 | for item in value: 61 | value_list.extend(self.to_python(item)) 62 | return value_list 63 | else: 64 | return value 65 | 66 | def clean(self, value): 67 | if self.required and not value: 68 | raise forms.ValidationError(self.error_messages['required'], 69 | code='required') 70 | value_list = self.to_python(value) 71 | return super(ScopeModelChoiceField, self).clean(value_list) 72 | 73 | 74 | class ScopeModelMixin(object): 75 | def clean_scope(self): 76 | default = Scope.objects.filter(name__in=DEFAULT_SCOPE.split(' ')) 77 | scope_qs = self.cleaned_data.get('scope', default) 78 | if scope_qs: 79 | return scope_qs 80 | else: 81 | return default 82 | 83 | 84 | class AuthorizationRequestForm(ScopeModelMixin, OAuthForm): 85 | """ 86 | This form is used to validate the request data that the authorization 87 | endpoint receives from clients. 88 | 89 | Included data is specified in :rfc:`4.1.1`. 90 | """ 91 | # Setting all required fields to false to explicitly check by hand 92 | # and use custom error messages that can be reused in the OAuth2 93 | # protocol 94 | response_type = forms.CharField(required=False) 95 | """ 96 | ``"code"`` or ``"token"`` depending on the grant type. 97 | """ 98 | 99 | redirect_uri = forms.URLField(required=False) 100 | """ 101 | Where the client would like to redirect the user 102 | back to. This has to match whatever value was saved while creating 103 | the client. 104 | """ 105 | 106 | state = forms.CharField(required=False) 107 | """ 108 | Opaque - just pass back to the client for validation. 109 | """ 110 | 111 | scope = ScopeModelChoiceField(queryset=Scope.objects.all(), required=False) 112 | """ 113 | The scope that the authorization should include. 114 | """ 115 | 116 | def clean_response_type(self): 117 | """ 118 | :rfc:`3.1.1` Lists of values are space delimited. 119 | """ 120 | response_type = self.cleaned_data.get('response_type') 121 | 122 | if not response_type: 123 | raise OAuthValidationError({'error': 'invalid_request', 124 | 'error_description': "No 'response_type' supplied."}) 125 | 126 | types = response_type.split(" ") 127 | 128 | for type in types: 129 | if type not in RESPONSE_TYPE_CHOICES: 130 | raise OAuthValidationError({ 131 | 'error': 'unsupported_response_type', 132 | 'error_description': u"'%s' is not a supported response " 133 | "type." % type}) 134 | 135 | return response_type 136 | 137 | def clean_redirect_uri(self): 138 | """ 139 | :rfc:`3.1.2` The redirect value has to match what was saved on the 140 | authorization server. 141 | """ 142 | redirect_uri = self.cleaned_data.get('redirect_uri') 143 | 144 | if redirect_uri: 145 | if not redirect_uri == self.client.redirect_uri: 146 | raise OAuthValidationError({ 147 | 'error': 'invalid_request', 148 | 'error_description': _("The requested redirect didn't " 149 | "match the client settings.")}) 150 | 151 | return redirect_uri 152 | 153 | 154 | class AuthorizationForm(ScopeModelMixin, OAuthForm): 155 | """ 156 | A form used to ask the resource owner for authorization of a given client. 157 | """ 158 | authorize = forms.BooleanField(required=False) 159 | scope = ScopeModelChoiceField(queryset=Scope.objects.all(), required=False) 160 | 161 | def save(self, **kwargs): 162 | authorize = self.cleaned_data.get('authorize') 163 | 164 | if not authorize: 165 | return None 166 | 167 | grant = Grant(**kwargs) 168 | grant.save() 169 | grant.scope.set(self.cleaned_data.get('scope')) 170 | return grant 171 | 172 | 173 | class RefreshTokenGrantForm(ScopeModelMixin, OAuthForm): 174 | """ 175 | Checks and returns a refresh token. 176 | """ 177 | refresh_token = forms.CharField(required=False) 178 | scope = ScopeModelChoiceField(queryset=Scope.objects.all(), required=False) 179 | 180 | def clean_refresh_token(self): 181 | token = self.cleaned_data.get('refresh_token') 182 | 183 | if not token: 184 | raise OAuthValidationError({'error': 'invalid_request'}) 185 | 186 | try: 187 | token = RefreshToken.objects.get(token=token, 188 | expired=False, client=self.client) 189 | except RefreshToken.DoesNotExist: 190 | raise OAuthValidationError({'error': 'invalid_grant'}) 191 | 192 | return token 193 | 194 | def clean(self): 195 | """ 196 | Make sure that the scope is less or equal to the previous scope! 197 | """ 198 | data = self.cleaned_data 199 | 200 | want_scope = data.get('scope') or None 201 | refresh_token = data.get('refresh_token') 202 | access_token = getattr(refresh_token, 'access_token', None) if \ 203 | refresh_token else \ 204 | None 205 | if refresh_token and want_scope: 206 | want_scope = {s.name for s in want_scope} 207 | has_scope = {s.name for s in access_token.scope.all()} 208 | if want_scope.issubset(has_scope): 209 | return data 210 | raise OAuthValidationError({'error': 'invalid_grant'}) 211 | 212 | 213 | class AuthorizationCodeGrantForm(ScopeModelMixin, OAuthForm): 214 | """ 215 | Check and return an authorization grant. 216 | """ 217 | code = forms.CharField(required=False) 218 | scope = ScopeModelChoiceField(queryset=Scope.objects.all(), required=False) 219 | 220 | def clean_code(self): 221 | code = self.cleaned_data.get('code') 222 | 223 | if not code: 224 | raise OAuthValidationError({'error': 'invalid_request'}) 225 | 226 | try: 227 | self.cleaned_data['grant'] = Grant.objects.get( 228 | code=code, client=self.client, expires__gt=now()) 229 | except Grant.DoesNotExist: 230 | raise OAuthValidationError({'error': 'invalid_grant'}) 231 | 232 | return code 233 | 234 | def clean(self): 235 | """ 236 | Make sure that the scope is less or equal to the scope allowed on the 237 | grant! 238 | """ 239 | data = self.cleaned_data 240 | want_scope = data.get('scope') or None 241 | grant = data.get('grant') 242 | if want_scope and grant: 243 | has_scope = {s.name for s in grant.scope.all()} 244 | want_scope = {s.name for s in want_scope} 245 | if want_scope.issubset(has_scope): 246 | return data 247 | raise OAuthValidationError({'error': 'invalid_grant'}) 248 | 249 | 250 | class PasswordGrantForm(ScopeModelMixin, OAuthForm): 251 | """ 252 | Validate the password of a user on a password grant request. 253 | """ 254 | username = forms.CharField(required=False) 255 | password = forms.CharField(required=False) 256 | scope = ScopeModelChoiceField(queryset=Scope.objects.all(), required=False) 257 | 258 | def clean_username(self): 259 | username = self.cleaned_data.get('username') 260 | 261 | if not username: 262 | raise OAuthValidationError({'error': 'invalid_request'}) 263 | 264 | return username 265 | 266 | def clean_password(self): 267 | password = self.cleaned_data.get('password') 268 | 269 | if not password: 270 | raise OAuthValidationError({'error': 'invalid_request'}) 271 | 272 | return password 273 | 274 | def clean(self): 275 | data = self.cleaned_data 276 | 277 | user = authenticate(username=data.get('username'), 278 | password=data.get('password')) 279 | 280 | if user is None: 281 | raise OAuthValidationError({'error': 'invalid_grant'}) 282 | 283 | data['user'] = user 284 | return data 285 | 286 | 287 | class PublicPasswordGrantForm(PasswordGrantForm): 288 | client_id = forms.CharField(required=True) 289 | grant_type = forms.CharField(required=True) 290 | 291 | def clean_grant_type(self): 292 | grant_type = self.cleaned_data.get('grant_type') 293 | 294 | if grant_type != 'password': 295 | raise OAuthValidationError({'error': 'invalid_grant'}) 296 | 297 | return grant_type 298 | 299 | def clean(self): 300 | data = super(PublicPasswordGrantForm, self).clean() 301 | 302 | try: 303 | client = Client.objects.get(client_id=data.get('client_id')) 304 | except Client.DoesNotExist: 305 | raise OAuthValidationError({'error': 'invalid_client'}) 306 | 307 | if client.client_type != PUBLIC: 308 | raise OAuthValidationError({'error': 'invalid_client'}) 309 | 310 | data['client'] = client 311 | return data 312 | 313 | 314 | class PublicClientForm(OAuthForm): 315 | client_id = forms.CharField(required=True) 316 | grant_type = forms.CharField(required=True) 317 | code = forms.CharField(required=True) 318 | redirect_uri = forms.CharField(required=False) 319 | 320 | def clean_grant_type(self): 321 | grant_type = self.cleaned_data.get('grant_type') 322 | 323 | if grant_type != 'authorization_code': 324 | raise OAuthValidationError({'error': 'invalid_grant'}) 325 | 326 | return grant_type 327 | 328 | def clean(self): 329 | data = super().clean() 330 | try: 331 | client = Client.objects.get( 332 | client_id=data.get('client_id'), 333 | client_type=PUBLIC, 334 | allow_public_token=True, 335 | ) 336 | except Client.DoesNotExist: 337 | raise OAuthValidationError({'error': 'invalid_client'}) 338 | now = timezone.now().astimezone(timezone.get_current_timezone()) 339 | try: 340 | redirect_uri = data.get('redirect_uri') 341 | grant = Grant.objects.get( 342 | client=client, 343 | code=data['code'], 344 | ) 345 | if grant.redirect_uri and grant.redirect_uri != data.get('redirect_uri'): 346 | raise OAuthValidationError({ 347 | 'error': 'invalid_grant', 348 | 'debug': f'redirect_uri: {redirect_uri}', 349 | }) 350 | if grant.expires < now: 351 | raise OAuthValidationError({ 352 | 'error': 'invalid_grant', 353 | 'debug': f'expries: {grant.expires}, now: {now}', 354 | }) 355 | except Grant.DoesNotExist: 356 | raise OAuthValidationError({'error': 'invalid_grant'}) 357 | 358 | data['client'] = client 359 | data['grant'] = grant 360 | return data 361 | -------------------------------------------------------------------------------- /provider/oauth2/middleware.py: -------------------------------------------------------------------------------- 1 | 2 | from django.conf import settings 3 | from django.contrib import auth 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.deprecation import MiddlewareMixin 6 | 7 | from provider.oauth2.models import AccessToken 8 | 9 | import logging 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class Oauth2UserMiddleware(MiddlewareMixin): 14 | """ 15 | Middleware for using OAuth credentials to authenticate requests 16 | 17 | If the request user is not authenticated the request is checked for 18 | oauth2 tokens and authenticated based on their presence. 19 | 20 | This module functions much in the same way that 21 | django.contrib.auth.middleware.RemoteUserMiddleware does and depends on 22 | django.contrib.auth.backends.RemoteUserBackend being enabled in order to 23 | authenticate the session. 24 | """ 25 | 26 | # Fixme: Not yet implemented 27 | def _http_access_token(self, request): 28 | 29 | try: 30 | auth_header = request.META.get('HTTP_AUTHORIZATION') 31 | if not auth_header: 32 | return None 33 | parts = auth_header.split() 34 | if len(parts) != 2: 35 | return None 36 | scope, token = parts 37 | if scope.lower() == "bearer": 38 | return token 39 | except: 40 | log.exception("Unable to parse access token!") 41 | 42 | def process_request(self, request): 43 | # AuthenticationMiddleware is required 44 | if not hasattr(request, 'user'): 45 | raise ImproperlyConfigured( 46 | "Authentication middleware is required for this module to work." 47 | " Insert 'django.contrib.auth.middleware.AuthenticationMiddleware'" 48 | " before this Oauth2UserMiddleware class." 49 | ) 50 | if 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS: 51 | raise ImproperlyConfigured( 52 | "Remote user authentication backend is required for this module to work." 53 | " Insert 'django.contrib.auth.backends.RemoteUserBackend' into the" 54 | " AUTHENTICATION_BACKENDS list in your settings." 55 | 56 | ) 57 | try: 58 | access_token_http = self._http_access_token(request) 59 | access_token_get = request.GET.get('access_token', access_token_http) 60 | access_token = request.POST.get('access_token', access_token_get) 61 | 62 | if not access_token: 63 | return 64 | 65 | try: 66 | token = AccessToken.objects.get_token(access_token) 67 | except Exception as e: 68 | log.error("Invalid access token: {} - " 69 | "{}: {}".format(access_token, e.__class__.__name__, e)) 70 | else: 71 | user = auth.authenticate(remote_user=token.user.username) 72 | auth.login(request, user) 73 | request.oauth2_client = token.client 74 | request.oauth2_token = token 75 | except Exception as e: 76 | log.error("Oauth2UserMiddleware encountered an exception! " 77 | "{}: {}".format(e.__class__.__name__, e)) 78 | 79 | def process_response(self, request, response): 80 | if hasattr(request, 'oauth2_token'): 81 | # Set modified=False to prevent the session from being stored and the cookie from being sent 82 | request.session.modified = False 83 | return response 84 | -------------------------------------------------------------------------------- /provider/oauth2/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import provider.utils 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='AccessToken', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('token', models.CharField(default=provider.utils.long_token, max_length=255, db_index=True)), 21 | ('expires', models.DateTimeField()), 22 | ], 23 | options={ 24 | 'db_table': 'oauth2_accesstoken', 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | migrations.CreateModel( 29 | name='AuthorizedClient', 30 | fields=[ 31 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 32 | ('authorized_at', models.DateTimeField(auto_now_add=True)), 33 | ], 34 | options={ 35 | 'db_table': 'oauth2_authorizedclient', 36 | }, 37 | bases=(models.Model,), 38 | ), 39 | migrations.CreateModel( 40 | name='Client', 41 | fields=[ 42 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 43 | ('name', models.CharField(max_length=255, blank=True)), 44 | ('url', models.URLField(help_text=b"Your application's URL.")), 45 | ('redirect_uri', models.URLField(help_text=b"Your application's callback URL")), 46 | ('client_id', models.CharField(default=provider.utils.short_token, max_length=255)), 47 | ('client_secret', models.CharField(default=provider.utils.long_token, max_length=255)), 48 | ('client_type', models.IntegerField(choices=[(0, b'Confidential (Web applications)'), (1, b'Public (Native and JS applications)')])), 49 | ('auto_authorize', models.BooleanField(default=False)), 50 | ('user', models.ForeignKey(related_name='oauth2_client', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.DO_NOTHING)), 51 | ], 52 | options={ 53 | 'db_table': 'oauth2_client', 54 | }, 55 | bases=(models.Model,), 56 | ), 57 | migrations.CreateModel( 58 | name='Grant', 59 | fields=[ 60 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 61 | ('code', models.CharField(default=provider.utils.long_token, max_length=255)), 62 | ('expires', models.DateTimeField(default=provider.utils.get_code_expiry)), 63 | ('redirect_uri', models.CharField(max_length=255, blank=True)), 64 | ('client', models.ForeignKey(to='oauth2.Client', on_delete=models.DO_NOTHING)), 65 | ], 66 | options={ 67 | 'db_table': 'oauth2_grant', 68 | }, 69 | bases=(models.Model,), 70 | ), 71 | migrations.CreateModel( 72 | name='RefreshToken', 73 | fields=[ 74 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 75 | ('token', models.CharField(default=provider.utils.long_token, max_length=255)), 76 | ('expired', models.BooleanField(default=False)), 77 | ('access_token', models.OneToOneField(related_name='refresh_token', to='oauth2.AccessToken', on_delete=models.DO_NOTHING)), 78 | ('client', models.ForeignKey(to='oauth2.Client', on_delete=models.DO_NOTHING)), 79 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING)), 80 | ], 81 | options={ 82 | 'db_table': 'oauth2_refreshtoken', 83 | }, 84 | bases=(models.Model,), 85 | ), 86 | migrations.CreateModel( 87 | name='Scope', 88 | fields=[ 89 | ('name', models.CharField(max_length=15, serialize=False, primary_key=True)), 90 | ('description', models.CharField(default=b'', max_length=256, blank=True)), 91 | ], 92 | options={ 93 | 'db_table': 'oauth2_scope', 94 | }, 95 | bases=(models.Model,), 96 | ), 97 | migrations.AddField( 98 | model_name='grant', 99 | name='scope', 100 | field=models.ManyToManyField(to='oauth2.Scope'), 101 | preserve_default=True, 102 | ), 103 | migrations.AddField( 104 | model_name='grant', 105 | name='user', 106 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING), 107 | preserve_default=True, 108 | ), 109 | migrations.AddField( 110 | model_name='authorizedclient', 111 | name='client', 112 | field=models.ForeignKey(to='oauth2.Client', on_delete=models.DO_NOTHING), 113 | preserve_default=True, 114 | ), 115 | migrations.AddField( 116 | model_name='authorizedclient', 117 | name='scope', 118 | field=models.ManyToManyField(to='oauth2.Scope'), 119 | preserve_default=True, 120 | ), 121 | migrations.AddField( 122 | model_name='authorizedclient', 123 | name='user', 124 | field=models.ForeignKey(related_name='oauth2_authorized_client', to=settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING), 125 | preserve_default=True, 126 | ), 127 | migrations.AlterUniqueTogether( 128 | name='authorizedclient', 129 | unique_together=set([('user', 'client')]), 130 | ), 131 | migrations.AddField( 132 | model_name='accesstoken', 133 | name='client', 134 | field=models.ForeignKey(to='oauth2.Client', on_delete=models.DO_NOTHING), 135 | preserve_default=True, 136 | ), 137 | migrations.AddField( 138 | model_name='accesstoken', 139 | name='scope', 140 | field=models.ManyToManyField(to='oauth2.Scope'), 141 | preserve_default=True, 142 | ), 143 | migrations.AddField( 144 | model_name='accesstoken', 145 | name='user', 146 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING), 147 | preserve_default=True, 148 | ), 149 | migrations.RunSQL("INSERT INTO oauth2_scope (name, description) values ('read', 'Read-Only access') "), 150 | ] 151 | -------------------------------------------------------------------------------- /provider/oauth2/migrations/0002_fix_scope_size.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-08-11 23:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='client', 15 | name='auto_authorize', 16 | field=models.BooleanField(blank=True, default=False), 17 | ), 18 | migrations.AlterField( 19 | model_name='client', 20 | name='client_type', 21 | field=models.IntegerField(choices=[(0, 'Confidential (Web applications)'), (1, 'Public (Native and JS applications)')]), 22 | ), 23 | migrations.AlterField( 24 | model_name='client', 25 | name='redirect_uri', 26 | field=models.URLField(help_text="Your application's callback URL"), 27 | ), 28 | migrations.AlterField( 29 | model_name='client', 30 | name='url', 31 | field=models.URLField(help_text="Your application's URL."), 32 | ), 33 | migrations.AlterField( 34 | model_name='scope', 35 | name='description', 36 | field=models.CharField(blank=True, default='', max_length=256), 37 | ), 38 | migrations.AlterField( 39 | model_name='scope', 40 | name='name', 41 | field=models.CharField(max_length=50, primary_key=True, serialize=False), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /provider/oauth2/migrations/0003_public_client_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2020-10-07 23:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('oauth2', '0002_fix_scope_size'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='client', 15 | name='allow_public_token', 16 | field=models.BooleanField(blank=True, default=False, help_text='Allow public client tokens with only client_id and code'), 17 | ), 18 | migrations.AddField( 19 | model_name='client', 20 | name='authorize_every_time', 21 | field=models.BooleanField(blank=True, default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /provider/oauth2/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/oauth2/migrations/__init__.py -------------------------------------------------------------------------------- /provider/oauth2/mixins.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import classonlymethod 2 | from django.http.response import JsonResponse 3 | 4 | 5 | class OAuthRegisteredScopes(object): 6 | scopes = set() 7 | 8 | 9 | class OAuthRequiredMixin(object): 10 | accepted_oauth_scopes = [] 11 | 12 | @classonlymethod 13 | def as_view(cls, *args, **kwargs): 14 | for scope in cls.accepted_oauth_scopes: 15 | OAuthRegisteredScopes.scopes.add(scope) 16 | 17 | return super(OAuthRequiredMixin, cls).as_view() 18 | 19 | def dispatch(self, request, *args, **kwargs): 20 | scopes = list() 21 | if hasattr(request, 'oauth2_token'): 22 | scopes = set(request.oauth2_token.scope.all().values_list('name', flat=True)) 23 | 24 | if request.user.is_authenticated and scopes.intersection(self.accepted_oauth_scopes): 25 | return super(OAuthRequiredMixin, self).dispatch(request, *args, **kwargs) 26 | 27 | return JsonResponse( 28 | { 29 | 'error': 'bad_access_token', 30 | 'accepted_scopes': sorted(self.accepted_oauth_scopes), 31 | 'token_scopes': sorted(scopes) 32 | }, 33 | status=401 34 | ) 35 | -------------------------------------------------------------------------------- /provider/oauth2/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Default model implementations. Custom database or OAuth backends need to 3 | implement these models with fields and and methods to be compatible with the 4 | views in :attr:`provider.views`. 5 | """ 6 | 7 | from django.db import models 8 | from django.conf import settings 9 | from provider import constants 10 | from provider.constants import CLIENT_TYPES 11 | from provider.utils import now, short_token, long_token, get_code_expiry 12 | from provider.utils import get_token_expiry 13 | 14 | from django.utils import timezone 15 | 16 | 17 | class Client(models.Model): 18 | """ 19 | Default client implementation. 20 | 21 | Expected fields: 22 | 23 | * :attr:`user` 24 | * :attr:`name` 25 | * :attr:`url` 26 | * :attr:`redirect_url` 27 | * :attr:`client_id` 28 | * :attr:`client_secret` 29 | * :attr:`client_type` 30 | 31 | Clients are outlined in the :rfc:`2` and its subsections. 32 | """ 33 | user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING, related_name='oauth2_client', 34 | blank=True, null=True) 35 | name = models.CharField(max_length=255, blank=True) 36 | url = models.URLField(help_text="Your application's URL.") 37 | redirect_uri = models.URLField(help_text="Your application's callback URL") 38 | client_id = models.CharField(max_length=255, default=short_token) 39 | client_secret = models.CharField(max_length=255, default=long_token) 40 | client_type = models.IntegerField(choices=CLIENT_TYPES) 41 | auto_authorize = models.BooleanField(default=False, blank=True) 42 | authorize_every_time = models.BooleanField(default=False, blank=True) 43 | allow_public_token = models.BooleanField(default=False, blank=True, 44 | help_text="Allow public client tokens with only client_id and code") 45 | 46 | def __unicode__(self): 47 | return self.redirect_uri 48 | 49 | def get_default_token_expiry(self): 50 | public = (self.client_type == constants.PUBLIC) 51 | return get_token_expiry(public) 52 | 53 | class Meta: 54 | app_label = 'oauth2' 55 | db_table = 'oauth2_client' 56 | 57 | 58 | class Scope(models.Model): 59 | name = models.CharField(max_length=50, primary_key=True) 60 | description = models.CharField(max_length=256, default='', blank=True) 61 | 62 | def __unicode__(self): 63 | return self.name 64 | 65 | class Meta: 66 | app_label = 'oauth2' 67 | db_table = 'oauth2_scope' 68 | 69 | 70 | class AuthorizedClientManager(models.Manager): 71 | def get_authorization(self, user, client): 72 | return self.get(user=user, client=client) 73 | 74 | def check_authorization_scope(self, user, client, scope_list): 75 | try: 76 | authorization = self.get_authorization(user, client) 77 | except AuthorizedClient.DoesNotExist: 78 | return None 79 | authorized_scopes = {s.name for s in authorization.scope.all()} 80 | if set(scope_list) <= authorized_scopes: 81 | return authorization 82 | return None 83 | 84 | def set_authorization_scope(self, user, client, scope_list): 85 | try: 86 | authorization = self.get_authorization(user, client) 87 | except AuthorizedClient.DoesNotExist: 88 | authorization = self.create(user=user, client=client) 89 | authorization.save() 90 | for s in scope_list: 91 | authorization.scope.add(s) 92 | return authorization 93 | 94 | 95 | class AuthorizedClient(models.Model): 96 | user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING, 97 | related_name='oauth2_authorized_client') 98 | client = models.ForeignKey('Client', models.DO_NOTHING) 99 | scope = models.ManyToManyField('Scope') 100 | authorized_at = models.DateTimeField(auto_now_add=True, blank=True) 101 | 102 | objects = AuthorizedClientManager() 103 | 104 | class Meta: 105 | app_label = 'oauth2' 106 | db_table = 'oauth2_authorizedclient' 107 | unique_together = ['user', 'client'] 108 | 109 | 110 | class Grant(models.Model): 111 | """ 112 | Default grant implementation. A grant is a code that can be swapped for an 113 | access token. Grants have a limited lifetime as defined by 114 | :attr:`provider.constants.EXPIRE_CODE_DELTA` and outlined in 115 | :rfc:`4.1.2` 116 | 117 | Expected fields: 118 | 119 | * :attr:`user` 120 | * :attr:`client` - :class:`Client` 121 | * :attr:`code` 122 | * :attr:`expires` - :attr:`datetime.datetime` 123 | * :attr:`redirect_uri` 124 | * :attr:`scope` 125 | """ 126 | user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING) 127 | client = models.ForeignKey('Client', models.DO_NOTHING) 128 | code = models.CharField(max_length=255, default=long_token) 129 | expires = models.DateTimeField(default=get_code_expiry) 130 | redirect_uri = models.CharField(max_length=255, blank=True) 131 | scope = models.ManyToManyField('Scope') 132 | 133 | def __unicode__(self): 134 | return self.code 135 | 136 | class Meta: 137 | app_label = 'oauth2' 138 | db_table = 'oauth2_grant' 139 | 140 | 141 | class AccessTokenManager(models.Manager): 142 | def get_token(self, token): 143 | return self.get(token=token, expires__gt=now()) 144 | 145 | def get_scoped_token(self, user, client, scope): 146 | obj = self.get(user=user, client=client, expires__gt=now()) 147 | obj_scopes = {s.name for s in obj.scope.all()} 148 | req_scopes = {s.name for s in scope} 149 | if set(req_scopes).issubset(obj_scopes): 150 | return obj 151 | raise AccessToken.DoesNotExist 152 | 153 | def create(self, scope=None, *args, **kwargs): 154 | obj = super(AccessTokenManager, self).create(*args, **kwargs) 155 | obj.save() 156 | if not scope: 157 | scope = list() 158 | for s in scope: 159 | obj.scope.add(s) 160 | return obj 161 | 162 | 163 | class AccessToken(models.Model): 164 | """ 165 | Default access token implementation. An access token is a time limited 166 | token to access a user's resources. 167 | 168 | Access tokens are outlined :rfc:`5`. 169 | 170 | Expected fields: 171 | 172 | * :attr:`user` 173 | * :attr:`token` 174 | * :attr:`client` - :class:`Client` 175 | * :attr:`expires` - :attr:`datetime.datetime` 176 | * :attr:`scope` 177 | 178 | Expected methods: 179 | 180 | * :meth:`get_expire_delta` - returns an integer representing seconds to 181 | expiry 182 | """ 183 | user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING) 184 | token = models.CharField(max_length=255, default=long_token, db_index=True) 185 | client = models.ForeignKey('Client', models.DO_NOTHING) 186 | expires = models.DateTimeField() 187 | scope = models.ManyToManyField('Scope') 188 | 189 | objects = AccessTokenManager() 190 | 191 | def __unicode__(self): 192 | return self.token 193 | 194 | def save(self, *args, **kwargs): 195 | if not self.expires: 196 | self.expires = self.client.get_default_token_expiry() 197 | super(AccessToken, self).save(*args, **kwargs) 198 | 199 | def get_expire_delta(self, reference=None): 200 | """ 201 | Return the number of seconds until this token expires. 202 | """ 203 | if reference is None: 204 | reference = now() 205 | expiration = self.expires 206 | 207 | if timezone: 208 | if timezone.is_aware(reference) and timezone.is_naive(expiration): 209 | # MySQL doesn't support timezone for datetime fields 210 | # so we assume that the date was stored in the UTC timezone 211 | expiration = timezone.make_aware(expiration, timezone.utc) 212 | elif timezone.is_naive(reference) and timezone.is_aware(expiration): 213 | reference = timezone.make_aware(reference, timezone.utc) 214 | 215 | timedelta = expiration - reference 216 | return timedelta.days*86400 + timedelta.seconds 217 | 218 | def get_scope_string(self): 219 | names = [s.name for s in self.scope.all()] 220 | names.sort() 221 | return ' '.join(names) 222 | 223 | class Meta: 224 | app_label = 'oauth2' 225 | db_table = 'oauth2_accesstoken' 226 | 227 | 228 | class RefreshTokenManager(models.Manager): 229 | def create(self, scope=None, *args, **kwargs): 230 | obj = super(RefreshTokenManager, self).create(*args, **kwargs) 231 | obj.save() 232 | if not scope: 233 | scope = list() 234 | for s in scope: 235 | obj.scope.add(s) 236 | return obj 237 | 238 | 239 | class RefreshToken(models.Model): 240 | """ 241 | Default refresh token implementation. A refresh token can be swapped for a 242 | new access token when said token expires. 243 | 244 | Expected fields: 245 | 246 | * :attr:`user` 247 | * :attr:`token` 248 | * :attr:`access_token` - :class:`AccessToken` 249 | * :attr:`client` - :class:`Client` 250 | * :attr:`expired` - ``boolean`` 251 | """ 252 | user = models.ForeignKey(settings.AUTH_USER_MODEL, models.DO_NOTHING) 253 | token = models.CharField(max_length=255, default=long_token) 254 | access_token = models.OneToOneField('AccessToken', models.DO_NOTHING, 255 | related_name='refresh_token') 256 | client = models.ForeignKey('Client', models.DO_NOTHING) 257 | expired = models.BooleanField(default=False) 258 | 259 | objects = RefreshTokenManager() 260 | 261 | def __unicode__(self): 262 | return self.token 263 | 264 | class Meta: 265 | app_label = 'oauth2' 266 | db_table = 'oauth2_refreshtoken' 267 | -------------------------------------------------------------------------------- /provider/oauth2/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/oauth2/tests/__init__.py -------------------------------------------------------------------------------- /provider/oauth2/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | from six.moves.urllib_parse import urlparse 3 | 4 | from django.shortcuts import reverse 5 | from django.http import QueryDict 6 | 7 | from provider.oauth2.models import Scope 8 | from provider.oauth2.mixins import OAuthRegisteredScopes 9 | from provider.oauth2.tests.test_views import BaseOAuth2TestCase 10 | 11 | 12 | class MiddlewareTestCase(BaseOAuth2TestCase): 13 | fixtures = ['test_oauth2.json'] 14 | 15 | def setUp(self): 16 | if not Scope.objects.filter(name='read').exists(): 17 | Scope.objects.create(name='read') 18 | 19 | def _login_authorize_get_token(self): 20 | required_props = ['access_token', 'token_type'] 21 | 22 | self.login() 23 | self._login_and_authorize() 24 | 25 | response = self.client.get(self.redirect_url()) 26 | query = QueryDict(urlparse(response['Location']).query) 27 | code = query['code'] 28 | 29 | response = self.client.post(self.access_token_url(), { 30 | 'grant_type': 'authorization_code', 31 | 'client_id': self.get_client().client_id, 32 | 'client_secret': self.get_client().client_secret, 33 | 'code': code}) 34 | 35 | self.assertEqual(200, response.status_code, response.content) 36 | 37 | token = json.loads(response.content) 38 | 39 | for prop in required_props: 40 | self.assertIn(prop, token, "Access token response missing " 41 | "required property: %s" % prop) 42 | 43 | return token 44 | 45 | def test_mixin_scopes(self): 46 | self.assertIn('read', OAuthRegisteredScopes.scopes) 47 | 48 | def test_no_token(self): 49 | # user_url = self.live_server_url + reverse('tests:user', args=[self.get_user().pk]) 50 | # result = requests.get(user_url) 51 | 52 | user_url = reverse('tests:user', args=[self.get_user().pk]) 53 | result = self.client.get(user_url) 54 | 55 | self.assertEqual(result.status_code, 401) 56 | 57 | def test_token_access(self): 58 | self.login() 59 | token_info = self._login_authorize_get_token() 60 | token = token_info['access_token'] 61 | 62 | # Create a new client to ensure a clean session 63 | oauth_client = self.client_class() 64 | 65 | user_url = reverse('tests:user', args=[self.get_user().pk]) 66 | result = oauth_client.get(user_url, {'access_token': token}) 67 | 68 | self.assertEqual(result.status_code, 200) 69 | result_json = result.json() 70 | self.assertEqual(result_json.get('id'), self.get_user().pk) 71 | 72 | def test_unauthorized_scope(self): 73 | self.login() 74 | token_info = self._login_authorize_get_token() 75 | token = token_info['access_token'] 76 | 77 | badscope_url = reverse('tests:badscope') 78 | 79 | oauth_client = self.client_class() 80 | 81 | result = oauth_client.get(badscope_url, {'access_token': token}) 82 | 83 | self.assertEqual(result.status_code, 401) 84 | result_json = result.json() 85 | # self.assertEqual(result_json.get('id'), self.get_user().pk) 86 | 87 | def test_no_stored_session(self): 88 | self.login() 89 | token_info = self._login_authorize_get_token() 90 | token = token_info['access_token'] 91 | 92 | oauth_client = self.client_class() 93 | 94 | user_url = reverse('tests:user', args=[self.get_user().pk]) 95 | result = oauth_client.get(user_url, {'access_token': token}) 96 | 97 | self.assertNotIn('sessionid', result.cookies) 98 | -------------------------------------------------------------------------------- /provider/oauth2/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import datetime 4 | from six.moves.urllib_parse import urlparse, parse_qs, quote 5 | 6 | from unittest import SkipTest 7 | from django.http import QueryDict 8 | from django.conf import settings 9 | from django.shortcuts import reverse 10 | from django.utils.html import escape 11 | from django.test import TestCase 12 | from django.contrib.auth.models import User 13 | from provider import constants, scope 14 | from provider.compat import skipIfCustomUser 15 | from provider.templatetags.scope import scopes 16 | from provider.utils import now as date_now 17 | from provider.oauth2.forms import ClientForm 18 | from provider.oauth2.models import Client, Grant, AccessToken, RefreshToken, AuthorizedClient 19 | from provider.oauth2.backends import BasicClientBackend, RequestParamsClientBackend 20 | from provider.oauth2.backends import AccessTokenBackend 21 | 22 | 23 | @skipIfCustomUser 24 | class BaseOAuth2TestCase(TestCase): 25 | def login(self): 26 | self.client.login(username='test-user-1', password='test') 27 | 28 | def auth_url(self): 29 | return reverse('oauth2:capture') 30 | 31 | def auth_url2(self): 32 | return reverse('oauth2:authorize') 33 | 34 | def redirect_url(self): 35 | return reverse('oauth2:redirect') 36 | 37 | def access_token_url(self): 38 | return reverse('oauth2:access_token') 39 | 40 | def get_client(self): 41 | return Client.objects.get(id=2) 42 | 43 | def get_public_client(self): 44 | return Client.objects.get(id=3) 45 | 46 | def get_grant(self): 47 | return Grant.objects.all()[0] 48 | 49 | def get_user(self): 50 | return User.objects.get(id=1) 51 | 52 | def get_password(self): 53 | return 'test' 54 | 55 | def _login_and_authorize(self, url_func=None): 56 | if url_func is None: 57 | def url_func(): 58 | return self.auth_url() + '?client_id={}&response_type=code&state=abc'.format( 59 | self.get_client().client_id 60 | ) 61 | 62 | response = self.client.get(url_func()) 63 | response = self.client.get(self.auth_url2()) 64 | 65 | response = self.client.post(self.auth_url2(), {'authorize': True, 'scope': 'read'}) 66 | self.assertEqual(302, response.status_code, response.content) 67 | self.assertIn(self.redirect_url(), response['Location']) 68 | 69 | 70 | class AuthorizationTest(BaseOAuth2TestCase): 71 | fixtures = ['test_oauth2'] 72 | 73 | def setUp(self): 74 | self._old_login = settings.LOGIN_URL 75 | settings.LOGIN_URL = '/login/' 76 | 77 | def tearDown(self): 78 | settings.LOGIN_URL = self._old_login 79 | 80 | def test_authorization_requires_login(self): 81 | response = self.client.get(self.auth_url()) 82 | 83 | # Login redirect 84 | self.assertEqual(302, response.status_code) 85 | self.assertEqual('/login/', urlparse(response['Location']).path) 86 | 87 | self.login() 88 | 89 | response = self.client.get(self.auth_url()) 90 | 91 | self.assertEqual(302, response.status_code) 92 | 93 | self.assertTrue(self.auth_url2() in response['Location']) 94 | 95 | def test_authorization_requires_client_id(self): 96 | self.login() 97 | response = self.client.get(self.auth_url()) 98 | response = self.client.get(self.auth_url2()) 99 | 100 | self.assertEqual(400, response.status_code) 101 | self.assertIn("An unauthorized client tried to access your resources.", response.content.decode('utf8')) 102 | 103 | def test_authorization_rejects_invalid_client_id(self): 104 | self.login() 105 | response = self.client.get(self.auth_url() + '?client_id=123') 106 | response = self.client.get(self.auth_url2()) 107 | 108 | self.assertEqual(400, response.status_code) 109 | self.assertIn(b"An unauthorized client tried to access your resources.", response.content) 110 | 111 | def test_authorization_requires_response_type(self): 112 | self.login() 113 | response = self.client.get(self.auth_url() + '?client_id=%s' % self.get_client().client_id) 114 | response = self.client.get(self.auth_url2()) 115 | 116 | self.assertEqual(400, response.status_code) 117 | self.assertIn(escape(u"No 'response_type' supplied."), response.content.decode('utf8')) 118 | 119 | def test_authorization_requires_supported_response_type(self): 120 | self.login() 121 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=unsupported' % self.get_client().client_id) 122 | response = self.client.get(self.auth_url2()) 123 | 124 | self.assertEqual(400, response.status_code) 125 | self.assertIn(escape(u"'unsupported' is not a supported response type."), response.content.decode('utf8')) 126 | 127 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code' % self.get_client().client_id) 128 | response = self.client.get(self.auth_url2()) 129 | self.assertEqual(200, response.status_code, response.content) 130 | 131 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=token' % self.get_client().client_id) 132 | response = self.client.get(self.auth_url2()) 133 | self.assertEqual(200, response.status_code) 134 | 135 | def test_authorization_requires_a_valid_redirect_uri(self): 136 | self.login() 137 | 138 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code&redirect_uri=%s' % ( 139 | self.get_client().client_id, 140 | self.get_client().redirect_uri + '-invalid')) 141 | response = self.client.get(self.auth_url2()) 142 | 143 | self.assertEqual(400, response.status_code) 144 | self.assertIn(escape("The requested redirect didn't match the client settings."), response.content.decode('utf8')) 145 | 146 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code&redirect_uri=%s' % ( 147 | self.get_client().client_id, 148 | self.get_client().redirect_uri)) 149 | response = self.client.get(self.auth_url2()) 150 | 151 | self.assertEqual(200, response.status_code) 152 | 153 | def test_authorization_requires_a_valid_scope(self): 154 | self.login() 155 | 156 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code&scope=invalid+invalid2' % self.get_client().client_id) 157 | 158 | self.assertEqual(400, response.status_code) 159 | self.assertIn(escape(u"Invalid scope."), response.content.decode('utf8')) 160 | 161 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code&scope=%s' % ( 162 | self.get_client().client_id, 163 | constants.SCOPES[0][1])) 164 | response = self.client.get(self.auth_url2()) 165 | self.assertEqual(200, response.status_code) 166 | 167 | def test_authorization_is_not_granted(self): 168 | self.login() 169 | 170 | response = self.client.get(self.auth_url() + '?client_id=%s&response_type=code' % self.get_client().client_id) 171 | response = self.client.get(self.auth_url2()) 172 | 173 | response = self.client.post(self.auth_url2(), {'authorize': False, 'scope': constants.SCOPES[0][1]}) 174 | self.assertEqual(302, response.status_code, response.content) 175 | self.assertTrue(self.redirect_url() in response['Location']) 176 | 177 | response = self.client.get(self.redirect_url()) 178 | 179 | self.assertEqual(302, response.status_code) 180 | self.assertTrue('error=access_denied' in response['Location']) 181 | self.assertFalse('code' in response['Location']) 182 | 183 | def test_authorization_is_granted(self): 184 | self.login() 185 | 186 | self._login_and_authorize() 187 | 188 | response = self.client.get(self.redirect_url()) 189 | 190 | self.assertEqual(302, response.status_code) 191 | self.assertFalse('error' in response['Location']) 192 | self.assertTrue('code' in response['Location']) 193 | 194 | def test_preserving_the_state_variable(self): 195 | self.login() 196 | 197 | self._login_and_authorize() 198 | 199 | response = self.client.get(self.redirect_url()) 200 | 201 | self.assertEqual(302, response.status_code) 202 | self.assertFalse('error' in response['Location']) 203 | self.assertTrue('code' in response['Location']) 204 | self.assertTrue('state=abc' in response['Location']) 205 | 206 | # # FIXME: Not sure what the error condition is that should exist here. 207 | # def test_redirect_requires_valid_data(self): 208 | # self.login() 209 | # response = self.client.get(self.redirect_url()) 210 | # self.assertEqual(400, response.status_code) 211 | 212 | 213 | class AccessTokenTest(BaseOAuth2TestCase): 214 | fixtures = ['test_oauth2.json'] 215 | 216 | def test_access_token_get_expire_delta_value(self): 217 | user = self.get_user() 218 | client = self.get_client() 219 | token = AccessToken.objects.create(user=user, client=client) 220 | now = date_now() 221 | default_expiration_timedelta = constants.EXPIRE_DELTA 222 | current_expiration_timedelta = datetime.timedelta(seconds=token.get_expire_delta(reference=now)) 223 | self.assertTrue(abs(current_expiration_timedelta - default_expiration_timedelta) <= datetime.timedelta(seconds=1)) 224 | 225 | def test_fetching_access_token_with_invalid_client(self): 226 | self.login() 227 | self._login_and_authorize() 228 | 229 | response = self.client.post(self.access_token_url(), { 230 | 'grant_type': 'authorization_code', 231 | 'client_id': self.get_client().client_id + '123', 232 | 'client_secret': self.get_client().client_secret, }) 233 | 234 | self.assertEqual(400, response.status_code, response.content) 235 | self.assertEqual('invalid_client', json.loads(response.content)['error']) 236 | 237 | def test_fetching_access_token_with_invalid_grant(self): 238 | self.login() 239 | self._login_and_authorize() 240 | 241 | response = self.client.post(self.access_token_url(), { 242 | 'grant_type': 'authorization_code', 243 | 'client_id': self.get_client().client_id, 244 | 'client_secret': self.get_client().client_secret, 245 | 'code': '123'}) 246 | 247 | self.assertEqual(400, response.status_code, response.content) 248 | # self.assertEqual('invalid_grant', json.loads(response.content)['error']) 249 | 250 | def test_authorize_once(self): 251 | state = 'abc' 252 | def url_func(): 253 | return self.auth_url() + '?client_id={}&response_type=code&state={}'.format( 254 | self.get_client().client_id, 255 | state, 256 | ) 257 | 258 | self.login() 259 | self._login_and_authorize(url_func) 260 | authorized_client = AuthorizedClient.objects.get() 261 | 262 | state = 'def' 263 | response = self.client.get(url_func()) 264 | self.assertEqual(response.url, "/oauth2/authorize/confirm") 265 | 266 | confirm_response = self.client.get(response.url) 267 | self.assertEqual(confirm_response.status_code, 302) 268 | 269 | def test_authorize_every_time(self): 270 | state = 'abc' 271 | def url_func(): 272 | return self.auth_url() + '?client_id={}&response_type=code&state={}'.format( 273 | self.get_public_client().client_id, 274 | state, 275 | ) 276 | 277 | self.login() 278 | self._login_and_authorize(url_func) 279 | 280 | state = 'def' 281 | response = self.client.get(url_func()) 282 | self.assertEqual(response.url, "/oauth2/authorize/confirm") 283 | 284 | def test_authorize_additional_scope(self): 285 | state = 'abc' 286 | scopes = 'basic' 287 | def url_func(): 288 | return self.auth_url() + '?client_id={}&response_type=code&state={}&scope={}'.format( 289 | self.get_public_client().client_id, 290 | state, 291 | quote(scopes), 292 | ) 293 | 294 | self.login() 295 | self._login_and_authorize(url_func) 296 | 297 | state = 'def' 298 | scopes = 'basic advanced' 299 | response = self.client.get(url_func()) 300 | self.assertEqual(response.url, "/oauth2/authorize/confirm") 301 | 302 | def _login_authorize_get_token(self): 303 | required_props = ['access_token', 'token_type'] 304 | 305 | self.login() 306 | self._login_and_authorize() 307 | 308 | response = self.client.get(self.redirect_url()) 309 | query = QueryDict(urlparse(response['Location']).query) 310 | code = query['code'] 311 | 312 | response = self.client.post(self.access_token_url(), { 313 | 'grant_type': 'authorization_code', 314 | 'client_id': self.get_client().client_id, 315 | 'client_secret': self.get_client().client_secret, 316 | 'code': code}) 317 | 318 | self.assertEqual(200, response.status_code, response.content) 319 | 320 | token = json.loads(response.content) 321 | 322 | for prop in required_props: 323 | self.assertIn(prop, token, "Access token response missing " 324 | "required property: %s" % prop) 325 | 326 | return token 327 | 328 | def test_fetching_access_token_with_valid_grant(self): 329 | self._login_authorize_get_token() 330 | 331 | def test_fetching_access_token_with_invalid_grant_type(self): 332 | self.login() 333 | self._login_and_authorize() 334 | response = self.client.get(self.redirect_url()) 335 | 336 | query = QueryDict(urlparse(response['Location']).query) 337 | code = query['code'] 338 | 339 | response = self.client.post(self.access_token_url(), { 340 | 'grant_type': 'invalid_grant_type', 341 | 'client_id': self.get_client().client_id, 342 | 'client_secret': self.get_client().client_secret, 343 | 'code': code 344 | }) 345 | 346 | self.assertEqual(400, response.status_code) 347 | self.assertEqual('unsupported_grant_type', json.loads(response.content)['error'], 348 | response.content) 349 | 350 | def test_fetching_access_token_multiple_times(self): 351 | self._login_authorize_get_token() 352 | code = self.get_grant().code 353 | 354 | response = self.client.post(self.access_token_url(), { 355 | 'grant_type': 'authorization_code', 356 | 'client_id': self.get_client().client_id, 357 | 'client_secret': self.get_client().client_secret, 358 | 'code': code}) 359 | 360 | self.assertEqual(400, response.status_code) 361 | # self.assertEqual('invalid_grant', json.loads(response.content)['error']) 362 | 363 | def test_access_token_native_client(self): 364 | def url_func(): 365 | return self.auth_url() + '?client_id={}&response_type=code&state=abc'.format( 366 | self.get_public_client().client_id 367 | ) 368 | 369 | self.login() 370 | self._login_and_authorize(url_func) 371 | 372 | self.assertEqual(self.get_public_client().client_type, constants.PUBLIC) 373 | 374 | response = self.client.get(self.redirect_url()) 375 | query = QueryDict(urlparse(response['Location']).query) 376 | code = query['code'] 377 | 378 | response = self.client.post(self.access_token_url(), { 379 | 'grant_type': 'authorization_code', 380 | 'client_id': self.get_public_client().client_id, 381 | 'code': code}) 382 | 383 | self.assertEqual(200, response.status_code, response.content) 384 | 385 | token_response = json.loads(response.content) 386 | 387 | self.assertNotIn('refresh_token', token_response) 388 | 389 | def test_escalating_the_scope(self): 390 | self.login() 391 | self._login_and_authorize() 392 | code = self.get_grant().code 393 | 394 | response = self.client.post(self.access_token_url(), { 395 | 'grant_type': 'authorization_code', 396 | 'client_id': self.get_client().client_id, 397 | 'client_secret': self.get_client().client_secret, 398 | 'code': code, 399 | 'scope': 'read write'}) 400 | 401 | self.assertEqual(400, response.status_code) 402 | self.assertEqual('invalid_grant', json.loads(response.content)['error']) 403 | 404 | def test_refreshing_an_access_token(self): 405 | token = self._login_authorize_get_token() 406 | 407 | response = self.client.post(self.access_token_url(), { 408 | 'grant_type': 'refresh_token', 409 | 'refresh_token': token['refresh_token'], 410 | 'client_id': self.get_client().client_id, 411 | 'client_secret': self.get_client().client_secret, 412 | }) 413 | 414 | self.assertEqual(200, response.status_code) 415 | 416 | response = self.client.post(self.access_token_url(), { 417 | 'grant_type': 'refresh_token', 418 | 'refresh_token': token['refresh_token'], 419 | 'client_id': self.get_client().client_id, 420 | 'client_secret': self.get_client().client_secret, 421 | }) 422 | 423 | self.assertEqual(400, response.status_code) 424 | self.assertEqual('invalid_grant', json.loads(response.content)['error'], 425 | response.content) 426 | 427 | def test_password_grant_public(self): 428 | c = self.get_client() 429 | c.client_type = constants.PUBLIC 430 | c.save() 431 | 432 | response = self.client.post(self.access_token_url(), { 433 | 'grant_type': 'password', 434 | 'client_id': c.client_id, 435 | # No secret needed 436 | 'username': self.get_user().username, 437 | 'password': self.get_password(), 438 | }) 439 | 440 | self.assertEqual(200, response.status_code, response.content) 441 | self.assertNotIn('refresh_token', json.loads(response.content)) 442 | expires_in = json.loads(response.content)['expires_in'] 443 | expires_in_days = round(expires_in / (60.0 * 60.0 * 24.0)) 444 | self.assertEqual(expires_in_days, constants.EXPIRE_DELTA_PUBLIC.days) 445 | 446 | def test_password_grant_confidential(self): 447 | c = self.get_client() 448 | c.client_type = constants.CONFIDENTIAL 449 | c.save() 450 | 451 | response = self.client.post(self.access_token_url(), { 452 | 'grant_type': 'password', 453 | 'client_id': c.client_id, 454 | 'client_secret': c.client_secret, 455 | 'username': self.get_user().username, 456 | 'password': self.get_password(), 457 | }) 458 | 459 | self.assertEqual(200, response.status_code, response.content) 460 | self.assertTrue(json.loads(response.content)['refresh_token']) 461 | 462 | def test_password_grant_confidential_no_secret(self): 463 | c = self.get_client() 464 | c.client_type = constants.CONFIDENTIAL 465 | c.save() 466 | 467 | response = self.client.post(self.access_token_url(), { 468 | 'grant_type': 'password', 469 | 'client_id': c.client_id, 470 | 'username': self.get_user().username, 471 | 'password': self.get_password(), 472 | }) 473 | 474 | self.assertEqual('invalid_client', json.loads(response.content)['error']) 475 | 476 | def test_password_grant_invalid_password_public(self): 477 | c = self.get_client() 478 | c.client_type = constants.PUBLIC 479 | c.save() 480 | 481 | response = self.client.post(self.access_token_url(), { 482 | 'grant_type': 'password', 483 | 'client_id': c.client_id, 484 | 'username': self.get_user().username, 485 | 'password': self.get_password() + 'invalid', 486 | }) 487 | 488 | self.assertEqual(400, response.status_code, response.content) 489 | self.assertEqual('invalid_client', json.loads(response.content)['error']) 490 | 491 | def test_password_grant_invalid_password_confidential(self): 492 | c = self.get_client() 493 | c.client_type = constants.CONFIDENTIAL 494 | c.save() 495 | 496 | response = self.client.post(self.access_token_url(), { 497 | 'grant_type': 'password', 498 | 'client_id': c.client_id, 499 | 'client_secret': c.client_secret, 500 | 'username': self.get_user().username, 501 | 'password': self.get_password() + 'invalid', 502 | }) 503 | 504 | self.assertEqual(400, response.status_code, response.content) 505 | self.assertEqual('invalid_grant', json.loads(response.content)['error']) 506 | 507 | def test_access_token_response_valid_token_type(self): 508 | token = self._login_authorize_get_token() 509 | self.assertEqual(token['token_type'], constants.TOKEN_TYPE, token) 510 | 511 | 512 | class AuthBackendTest(BaseOAuth2TestCase): 513 | fixtures = ['test_oauth2'] 514 | 515 | def test_basic_client_backend(self): 516 | request = type('Request', (object,), {'META': {}})() 517 | user_pass = "{0}:{1}".format( 518 | self.get_client().client_id, 519 | self.get_client().client_secret 520 | ) 521 | user_pass64 = base64.b64encode(user_pass.encode('utf8')).decode('utf8') 522 | request.META['HTTP_AUTHORIZATION'] = "Basic {}".format(user_pass64) 523 | 524 | self.assertEqual(BasicClientBackend().authenticate(request).id, 525 | 2, "Didn't return the right client.") 526 | 527 | def test_request_params_client_backend(self): 528 | request = type('Request', (object,), {'REQUEST': {}})() 529 | 530 | request.REQUEST['client_id'] = self.get_client().client_id 531 | request.REQUEST['client_secret'] = self.get_client().client_secret 532 | 533 | self.assertEqual(RequestParamsClientBackend().authenticate(request).id, 534 | 2, "Didn't return the right client.'") 535 | 536 | def test_access_token_backend(self): 537 | user = self.get_user() 538 | client = self.get_client() 539 | backend = AccessTokenBackend() 540 | token = AccessToken.objects.create(user=user, client=client) 541 | authenticated = backend.authenticate(access_token=token.token, 542 | client=client) 543 | 544 | self.assertIsNotNone(authenticated) 545 | 546 | 547 | class EnforceSecureTest(BaseOAuth2TestCase): 548 | fixtures = ['test_oauth2'] 549 | 550 | def setUp(self): 551 | constants.ENFORCE_SECURE = True 552 | 553 | def tearDown(self): 554 | constants.ENFORCE_SECURE = False 555 | 556 | def test_authorization_enforces_SSL(self): 557 | self.login() 558 | 559 | response = self.client.get(self.auth_url()) 560 | 561 | self.assertEqual(400, response.status_code) 562 | self.assertIn("A secure connection is required.", response.content.decode('utf8')) 563 | 564 | def test_access_token_enforces_SSL(self): 565 | response = self.client.post(self.access_token_url(), {}) 566 | 567 | self.assertEqual(400, response.status_code) 568 | self.assertIn("A secure connection is required.", response.content.decode('utf8')) 569 | 570 | 571 | class ClientFormTest(TestCase): 572 | def test_client_form(self): 573 | form = ClientForm({'name': 'TestName', 'url': 'http://127.0.0.1:8000', 574 | 'redirect_uri': 'http://localhost:8000/'}) 575 | 576 | self.assertFalse(form.is_valid()) 577 | 578 | form = ClientForm({ 579 | 'name': 'TestName', 580 | 'url': 'http://127.0.0.1:8000', 581 | 'redirect_uri': 'http://localhost:8000/', 582 | 'client_type': constants.CONFIDENTIAL}) 583 | self.assertTrue(form.is_valid()) 584 | form.save() 585 | 586 | 587 | class ScopeTest(TestCase): 588 | def setUp(self): 589 | self._scopes = constants.SCOPES 590 | constants.SCOPES = constants.DEFAULT_SCOPES 591 | 592 | def tearDown(self): 593 | constants.SCOPES = self._scopes 594 | 595 | def test_get_scope_names(self): 596 | names = scope.to_names(constants.READ) 597 | self.assertEqual('read', ' '.join(names)) 598 | 599 | names = scope.names(constants.READ_WRITE) 600 | names.sort() 601 | 602 | self.assertEqual('read read+write write', ' '.join(names)) 603 | 604 | def test_get_scope_ints(self): 605 | self.assertEqual(constants.READ, scope.to_int('read')) 606 | self.assertEqual(constants.WRITE, scope.to_int('write')) 607 | self.assertEqual(constants.READ_WRITE, scope.to_int('read', 'write')) 608 | self.assertEqual(0, scope.to_int('invalid')) 609 | self.assertEqual(1, scope.to_int('invalid', default=1)) 610 | 611 | def test_template_filter(self): 612 | names = scopes(constants.READ) 613 | self.assertEqual('read', ' '.join(names)) 614 | 615 | names = scope.names(constants.READ_WRITE) 616 | names.sort() 617 | 618 | self.assertEqual('read read+write write', ' '.join(names)) 619 | 620 | 621 | class DeleteExpiredTest(BaseOAuth2TestCase): 622 | fixtures = ['test_oauth2'] 623 | 624 | def setUp(self): 625 | self._delete_expired = constants.DELETE_EXPIRED 626 | constants.DELETE_EXPIRED = True 627 | 628 | def tearDown(self): 629 | constants.DELETE_EXPIRED = self._delete_expired 630 | 631 | def test_clear_expired(self): 632 | self.login() 633 | 634 | self._login_and_authorize() 635 | 636 | response = self.client.get(self.redirect_url()) 637 | 638 | self.assertEqual(302, response.status_code) 639 | location = response['Location'] 640 | self.assertNotIn('error', location) 641 | self.assertIn('code', location) 642 | print(location) 643 | # verify that Grant with code exists 644 | parsed_location = urlparse(location) 645 | code = parse_qs(parsed_location.query)['code'][0] 646 | self.assertTrue(Grant.objects.filter(code=code).exists()) 647 | 648 | # use the code/grant 649 | response = self.client.post(self.access_token_url(), { 650 | 'grant_type': 'authorization_code', 651 | 'client_id': self.get_client().client_id, 652 | 'client_secret': self.get_client().client_secret, 653 | 'code': code}) 654 | self.assertEquals(200, response.status_code) 655 | token = json.loads(response.content) 656 | self.assertTrue('access_token' in token) 657 | access_token = token['access_token'] 658 | self.assertTrue('refresh_token' in token) 659 | refresh_token = token['refresh_token'] 660 | 661 | # make sure the grant is gone 662 | self.assertFalse(Grant.objects.filter(code=code).exists()) 663 | # and verify that the AccessToken and RefreshToken exist 664 | self.assertTrue(AccessToken.objects.filter(token=access_token) 665 | .exists()) 666 | self.assertTrue(RefreshToken.objects.filter(token=refresh_token) 667 | .exists()) 668 | 669 | # refresh the token 670 | response = self.client.post(self.access_token_url(), { 671 | 'grant_type': 'refresh_token', 672 | 'refresh_token': token['refresh_token'], 673 | 'client_id': self.get_client().client_id, 674 | 'client_secret': self.get_client().client_secret, 675 | }) 676 | self.assertEqual(200, response.status_code) 677 | token = json.loads(response.content) 678 | self.assertTrue('access_token' in token) 679 | self.assertNotEquals(access_token, token['access_token']) 680 | self.assertTrue('refresh_token' in token) 681 | self.assertNotEquals(refresh_token, token['refresh_token']) 682 | 683 | # make sure the orig AccessToken and RefreshToken are gone 684 | self.assertFalse(AccessToken.objects.filter(token=access_token) 685 | .exists()) 686 | self.assertFalse(RefreshToken.objects.filter(token=refresh_token) 687 | .exists()) 688 | -------------------------------------------------------------------------------- /provider/oauth2/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.http.response import JsonResponse 3 | from django.views.generic import View 4 | from django.contrib.auth.mixins import LoginRequiredMixin 5 | from django.contrib.auth.models import User 6 | from django.shortcuts import get_object_or_404 7 | 8 | from provider.oauth2.mixins import OAuthRequiredMixin 9 | 10 | app_name = 'tests' 11 | 12 | 13 | class UserView(OAuthRequiredMixin, LoginRequiredMixin, View): 14 | accepted_oauth_scopes = ['read'] 15 | 16 | def get(self, request, *args, **kwargs): 17 | user = get_object_or_404(User, pk=self.kwargs['pk']) 18 | return JsonResponse( 19 | { 20 | 'username': user.username, 21 | 'id': user.pk, 22 | } 23 | ) 24 | 25 | 26 | class BadScopeView(OAuthRequiredMixin, LoginRequiredMixin, View): 27 | accepted_oauth_scopes = ['badscope'] 28 | 29 | def get(self, request, *args, **kwargs): 30 | user = self.request.user 31 | return JsonResponse( 32 | { 33 | 'username': user.username, 34 | 'id': user.pk, 35 | } 36 | ) 37 | 38 | 39 | urlpatterns = [ 40 | url('^badscope$', BadScopeView.as_view(), name='badscope'), 41 | url('^user/(?P\d+)$', UserView.as_view(), name='user'), 42 | ] 43 | -------------------------------------------------------------------------------- /provider/oauth2/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | The default implementation of the OAuth provider includes two public endpoints 3 | that are meant for client (as defined in :rfc:`1`) interaction. 4 | 5 | .. attribute:: ^authorize/$ 6 | 7 | This is the URL where a client should redirect a user to for authorization. 8 | 9 | This endpoint expects the parameters defined in :rfc:`4.1.1` and returns 10 | responses as defined in :rfc:`4.1.2` and :rfc:`4.1.2.1`. 11 | 12 | .. attribute:: ^access_token/$ 13 | 14 | This is the URL where a client exchanges a grant for an access tokens. 15 | 16 | This endpoint expects different parameters depending on the grant type: 17 | 18 | * Access tokens: :rfc:`4.1.3` 19 | * Refresh tokens: :rfc:`6` 20 | * Password grant: :rfc:`4.3.2` 21 | 22 | This endpoint returns responses depending on the grant type: 23 | 24 | * Access tokens: :rfc:`4.1.4` and :rfc:`5.1` 25 | * Refresh tokens: :rfc:`4.1.4` and :rfc:`5.1` 26 | * Password grant: :rfc:`5.1` 27 | 28 | To override, remove or add grant types, override the appropriate methods on 29 | :class:`provider.views.AccessToken` and / or 30 | :class:`provider.oauth2.views.AccessTokenView`. 31 | 32 | Errors are outlined in :rfc:`5.2`. 33 | 34 | """ 35 | 36 | from django.contrib.auth.decorators import login_required 37 | from django.views.decorators.csrf import csrf_exempt 38 | from django.conf.urls import url, include 39 | from provider.oauth2 import views 40 | 41 | app_name = 'oauth2' 42 | 43 | urlpatterns = [ 44 | url('^authorize/?$', 45 | login_required(views.CaptureView.as_view()), 46 | name='capture'), 47 | url('^authorize/confirm/?$', 48 | login_required(views.AuthorizeView.as_view()), 49 | name='authorize'), 50 | url('^redirect/?$', 51 | login_required(views.RedirectView.as_view()), 52 | name='redirect'), 53 | url('^access_token/?$', 54 | csrf_exempt(views.AccessTokenView.as_view()), 55 | name='access_token'), 56 | ] 57 | -------------------------------------------------------------------------------- /provider/oauth2/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.shortcuts import reverse 3 | from provider import constants 4 | from provider.views import CaptureViewBase, AuthorizeViewBase, RedirectViewBase 5 | from provider.views import AccessTokenViewBase, OAuthError 6 | from provider.utils import now 7 | from provider.oauth2 import forms 8 | from provider.oauth2 import models 9 | from provider.oauth2 import backends 10 | 11 | class CaptureView(CaptureViewBase): 12 | """ 13 | Implementation of :class:`provider.views.Capture`. 14 | """ 15 | 16 | def validate_scopes(self, scope_list): 17 | scopes = {s.name for s in 18 | models.Scope.objects.filter(name__in=scope_list)} 19 | return set(scope_list).issubset(scopes) 20 | 21 | def get_redirect_url(self, request): 22 | return reverse('oauth2:authorize') 23 | 24 | 25 | class AuthorizeView(AuthorizeViewBase): 26 | """ 27 | Implementation of :class:`provider.views.Authorize`. 28 | """ 29 | def get_request_form(self, client, data): 30 | return forms.AuthorizationRequestForm(data, client=client) 31 | 32 | def get_authorization_form(self, request, client, data, client_data): 33 | return forms.AuthorizationForm(data) 34 | 35 | def get_client(self, client_id): 36 | try: 37 | return models.Client.objects.get(client_id=client_id) 38 | except models.Client.DoesNotExist: 39 | return None 40 | 41 | def get_redirect_url(self, request): 42 | return reverse('oauth2:redirect') 43 | 44 | def has_authorization(self, request, client, scope_list): 45 | if client.auto_authorize: 46 | return True 47 | if client.authorize_every_time: 48 | return False 49 | 50 | authclient_mgr = models.AuthorizedClient.objects 51 | auth = authclient_mgr.check_authorization_scope(request.user, 52 | client, 53 | scope_list) 54 | return bool(auth) 55 | 56 | def save_authorization(self, request, client, form, client_data): 57 | 58 | scope_list = {s for s in form.cleaned_data['scope']} 59 | models.AuthorizedClient.objects.set_authorization_scope(request.user, 60 | client, 61 | scope_list) 62 | 63 | grant = form.save(user=request.user, 64 | client=client, 65 | redirect_uri=client_data.get('redirect_uri', '')) 66 | 67 | if grant is None: 68 | return None 69 | 70 | grant.user = request.user 71 | grant.client = client 72 | grant.redirect_uri = client_data.get('redirect_uri', '') 73 | grant.save() 74 | return grant.code 75 | 76 | 77 | class RedirectView(RedirectViewBase): 78 | """ 79 | Implementation of :class:`provider.views.Redirect` 80 | """ 81 | pass 82 | 83 | 84 | class AccessTokenView(AccessTokenViewBase): 85 | """ 86 | Implementation of :class:`provider.views.AccessToken`. 87 | 88 | .. note:: This implementation does provide all default grant types defined 89 | in :attr:`provider.views.AccessToken.grant_types`. If you 90 | wish to disable any, you can override the :meth:`get_handler` method 91 | *or* the :attr:`grant_types` list. 92 | """ 93 | authentication = ( 94 | backends.BasicClientBackend, 95 | backends.RequestParamsClientBackend, 96 | backends.PublicPasswordBackend, 97 | backends.PublicClientBackend, 98 | ) 99 | 100 | def get_authorization_code_grant(self, request, data, client): 101 | form = forms.AuthorizationCodeGrantForm(data, client=client) 102 | if not form.is_valid(): 103 | raise OAuthError(form.errors) 104 | return form.cleaned_data.get('grant') 105 | 106 | def get_refresh_token_grant(self, request, data, client): 107 | form = forms.RefreshTokenGrantForm(data, client=client) 108 | if not form.is_valid(): 109 | raise OAuthError(form.errors) 110 | return form.cleaned_data.get('refresh_token') 111 | 112 | def get_password_grant(self, request, data, client): 113 | form = forms.PasswordGrantForm(data, client=client) 114 | if not form.is_valid(): 115 | raise OAuthError(form.errors) 116 | return form.cleaned_data 117 | 118 | def get_access_token(self, request, user, scope, client): 119 | try: 120 | # Attempt to fetch an existing access token. 121 | at = models.AccessToken.objects.get_scoped_token(user, client, scope) 122 | except models.AccessToken.DoesNotExist: 123 | # None found... make a new one! 124 | at = self.create_access_token(request, user, scope, client) 125 | if client.client_type != constants.PUBLIC: 126 | self.create_refresh_token(request, user, scope, at, client) 127 | return at 128 | 129 | def create_access_token(self, request, user, scope, client): 130 | at = models.AccessToken.objects.create( 131 | user=user, 132 | client=client, 133 | ) 134 | for s in scope: 135 | at.scope.add(s) 136 | return at 137 | 138 | def create_refresh_token(self, request, user, scope, access_token, client): 139 | return models.RefreshToken.objects.create( 140 | user=user, 141 | access_token=access_token, 142 | client=client, 143 | ) 144 | 145 | def invalidate_grant(self, grant): 146 | if constants.DELETE_EXPIRED: 147 | grant.delete() 148 | else: 149 | grant.expires = now() - timedelta(days=1) 150 | grant.save() 151 | 152 | def invalidate_refresh_token(self, rt): 153 | if constants.DELETE_EXPIRED: 154 | rt.delete() 155 | else: 156 | rt.expired = True 157 | rt.save() 158 | 159 | def invalidate_access_token(self, at): 160 | if constants.DELETE_EXPIRED: 161 | at.delete() 162 | else: 163 | at.expires = now() - timedelta(days=1) 164 | at.save() 165 | -------------------------------------------------------------------------------- /provider/scope.py: -------------------------------------------------------------------------------- 1 | """ 2 | Default scope implementation relying on bit shifting. See 3 | :attr:`provider.constants.SCOPES` for the list of available scopes. 4 | 5 | Scopes can be combined, such as ``"read write"``. Note that a single 6 | ``"write"`` scope is *not* the same as ``"read write"``. 7 | 8 | See :class:`provider.scope.to_int` on how scopes are combined. 9 | """ 10 | from functools import reduce 11 | 12 | from .constants import SCOPES 13 | 14 | SCOPE_NAMES = [(name, name) for (value, name) in SCOPES] 15 | SCOPE_NAME_DICT = dict([(name, value) for (value, name) in SCOPES]) 16 | SCOPE_VALUE_DICT = dict([(value, name) for (value, name) in SCOPES]) 17 | 18 | 19 | def check(wants, has): 20 | """ 21 | Check if a desired scope ``wants`` is part of an available scope ``has``. 22 | 23 | Returns ``False`` if not, return ``True`` if yes. 24 | 25 | :example: 26 | 27 | If a list of scopes such as 28 | 29 | :: 30 | 31 | READ = 1 << 1 32 | WRITE = 1 << 2 33 | READ_WRITE = READ | WRITE 34 | 35 | SCOPES = ( 36 | (READ, 'read'), 37 | (WRITE, 'write'), 38 | (READ_WRITE, 'read+write'), 39 | ) 40 | 41 | is defined, we can check if a given scope is part of another: 42 | 43 | :: 44 | 45 | >>> from provider import scope 46 | >>> scope.check(READ, READ) 47 | True 48 | >>> scope.check(WRITE, READ) 49 | False 50 | >>> scope.check(WRITE, WRITE) 51 | True 52 | >>> scope.check(READ, WRITE) 53 | False 54 | >>> scope.check(READ, READ_WRITE) 55 | True 56 | >>> scope.check(WRITE, READ_WRITE) 57 | True 58 | 59 | """ 60 | if wants & has == 0: 61 | return False 62 | if wants & has < wants: 63 | return False 64 | return True 65 | 66 | 67 | def to_names(scope): 68 | """ 69 | Returns a list of scope names as defined in 70 | :attr:`provider.constants.SCOPES` for a given scope integer. 71 | 72 | >>> assert ['read', 'write'] == provider.scope.names(provider.constants.READ_WRITE) 73 | 74 | """ 75 | return [ 76 | name 77 | for (name, value) in SCOPE_NAME_DICT.items() 78 | if check(value, scope) 79 | ] 80 | 81 | # Keep it compatible 82 | names = to_names 83 | 84 | 85 | def to_int(*names, **kwargs): 86 | """ 87 | Turns a list of scope names into an integer value. 88 | 89 | :: 90 | 91 | >>> scope.to_int('read') 92 | 2 93 | >>> scope.to_int('write') 94 | 6 95 | >>> scope.to_int('read', 'write') 96 | 6 97 | >>> scope.to_int('invalid') 98 | 0 99 | >>> scope.to_int('invalid', default = 1) 100 | 1 101 | 102 | """ 103 | 104 | return reduce(lambda prev, next: (prev | SCOPE_NAME_DICT.get(next, 0)), 105 | names, kwargs.pop('default', 0)) 106 | -------------------------------------------------------------------------------- /provider/sphinx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom Sphinx documentation module to link to parts of the OAuth2 draft. 3 | """ 4 | from docutils import nodes, utils 5 | 6 | base_url = "http://tools.ietf.org/html/rfc6749" 7 | 8 | def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]): 9 | """Link to the OAuth2 draft. 10 | 11 | Returns 2 part tuple containing list of nodes to insert into the 12 | document and a list of system messages. Both are allowed to be 13 | empty. 14 | 15 | :param name: The role name used in the document. 16 | :param rawtext: The entire markup snippet, with role. 17 | :param text: The text marked with the role. 18 | :param lineno: The line number where rawtext appears in the input. 19 | :param inliner: The inliner instance that called us. 20 | :param options: Directive options for customization. 21 | :param content: The directive content for customization. 22 | """ 23 | 24 | node = nodes.reference(rawtext, "Section " + text, refuri="%s#section-%s" % (base_url, text)) 25 | 26 | return [node], [] 27 | 28 | def setup(app): 29 | """ 30 | Install the plugin. 31 | 32 | :param app: Sphinx application context. 33 | """ 34 | app.add_role('rfc', rfclink) 35 | return 36 | -------------------------------------------------------------------------------- /provider/templates/provider/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load scope %} 3 | {% block content %} 4 | {% if not error %} 5 |

{{ client.name }} would like to access your data with the following permissions:

6 |
    7 | {% for permission in oauth_data.scope %} 8 |
  • 9 | {% if permission.description %} 10 | {{ permission.description }} 11 | {% else %} 12 | {{ permission.name }} 13 | {% endif %} 14 |
  • 15 | {% endfor %} 16 |
17 |
18 | {% csrf_token %} 19 | {{ form.errors }} 20 | {{ form.non_field_errors }} 21 |
22 |
23 | 34 |
35 | 36 | 37 |
38 |
39 | {% else %} 40 | {{ error }} 41 | {{ error_description }} 42 | {% endif %} 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /provider/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/templatetags/__init__.py -------------------------------------------------------------------------------- /provider/templatetags/scope.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from .. import scope 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def scopes(scope_int): 9 | """ 10 | Wrapper around :attr:`provider.scope.names` to turn an int into a list 11 | of scope names in templates. 12 | """ 13 | return scope.to_names(scope_int) 14 | -------------------------------------------------------------------------------- /provider/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/tests/__init__.py -------------------------------------------------------------------------------- /provider/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test cases for functionality provided by the provider.utils module 3 | """ 4 | 5 | from django.test import TestCase 6 | 7 | 8 | class UtilsTestCase(TestCase): 9 | pass 10 | -------------------------------------------------------------------------------- /provider/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/provider/urls.py -------------------------------------------------------------------------------- /provider/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import shortuuid 3 | from django.conf import settings 4 | from provider.constants import EXPIRE_DELTA, EXPIRE_DELTA_PUBLIC, EXPIRE_CODE_DELTA 5 | 6 | from django.utils import timezone 7 | 8 | 9 | def now(): 10 | return timezone.now() 11 | 12 | 13 | def short_token(): 14 | """ 15 | Generate a hash that can be used as an application identifier 16 | """ 17 | hash = hashlib.sha1(shortuuid.uuid().encode('utf8')) 18 | hash.update(settings.SECRET_KEY.encode('utf8')) 19 | return hash.hexdigest()[::2] 20 | 21 | 22 | def long_token(): 23 | """ 24 | Generate a hash that can be used as an application secret 25 | """ 26 | hash = hashlib.sha1(shortuuid.uuid().encode('utf8')) 27 | hash.update(settings.SECRET_KEY.encode('utf8')) 28 | return hash.hexdigest() 29 | 30 | 31 | def get_token_expiry(public=True): 32 | """ 33 | Return a datetime object indicating when an access token should expire. 34 | Can be customized by setting :attr:`settings.OAUTH_EXPIRE_DELTA` to a 35 | :attr:`datetime.timedelta` object. 36 | """ 37 | if public: 38 | return now() + EXPIRE_DELTA_PUBLIC 39 | else: 40 | return now() + EXPIRE_DELTA 41 | 42 | 43 | def get_code_expiry(): 44 | """ 45 | Return a datetime object indicating when an authorization code should 46 | expire. 47 | Can be customized by setting :attr:`settings.OAUTH_EXPIRE_CODE_DELTA` to a 48 | :attr:`datetime.timedelta` object. 49 | """ 50 | return now() + EXPIRE_CODE_DELTA 51 | -------------------------------------------------------------------------------- /provider/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | 5 | from six.moves.urllib_parse import urlparse, ParseResult 6 | 7 | from django.http import HttpResponse 8 | from django.http import HttpResponseRedirect, QueryDict 9 | from django.utils.translation import ugettext as _ 10 | from django.views.generic.base import TemplateView, View 11 | from django.core.exceptions import ObjectDoesNotExist 12 | from provider.oauth2.models import Client, Scope 13 | from provider import constants 14 | 15 | 16 | class OAuthError(Exception): 17 | """ 18 | Exception to throw inside any views defined in :attr:`provider.views`. 19 | 20 | Any :attr:`OAuthError` thrown will be signalled to the API consumer. 21 | 22 | :attr:`OAuthError` expects a dictionary as its first argument outlining the 23 | type of error that occured. 24 | 25 | :example: 26 | 27 | :: 28 | 29 | raise OAuthError({'error': 'invalid_request'}) 30 | 31 | The different types of errors are outlined in :rfc:`4.2.2.1` and 32 | :rfc:`5.2`. 33 | 34 | """ 35 | 36 | 37 | class AuthUtilMixin(object): 38 | """ 39 | Mixin providing common methods required in the OAuth view defined in 40 | :attr:`provider.views`. 41 | """ 42 | def get_data(self, request, key='params'): 43 | """ 44 | Return stored data from the session store. 45 | 46 | :param key: `str` The key under which the data was stored. 47 | """ 48 | return request.session.get('%s:%s' % (constants.SESSION_KEY, key)) 49 | 50 | def cache_data(self, request, data, key='params'): 51 | """ 52 | Cache data in the session store. 53 | 54 | :param request: :attr:`django.http.HttpRequest` 55 | :param data: Arbitrary data to store. 56 | :param key: `str` The key under which to store the data. 57 | """ 58 | request.session['%s:%s' % (constants.SESSION_KEY, key)] = data 59 | 60 | def clear_data(self, request): 61 | """ 62 | Clear all OAuth related data from the session store. 63 | """ 64 | for key in list(request.session.keys()): 65 | if key.startswith(constants.SESSION_KEY): 66 | del request.session[key] 67 | 68 | def authenticate(self, request): 69 | """ 70 | Authenticate a client against all the backends configured in 71 | :attr:`authentication`. 72 | """ 73 | for backend in self.authentication: 74 | client = backend().authenticate(request) 75 | if client is not None: 76 | return client 77 | return None 78 | 79 | 80 | class CaptureViewBase(AuthUtilMixin, TemplateView): 81 | """ 82 | As stated in section :rfc:`3.1.2.5` this view captures all the request 83 | parameters and redirects to another URL to avoid any leakage of request 84 | parameters to potentially harmful JavaScripts. 85 | 86 | This application assumes that whatever web-server is used as front-end will 87 | handle SSL transport. 88 | 89 | If you want strict enforcement of secure communication at application 90 | level, set :attr:`settings.OAUTH_ENFORCE_SECURE` to ``True``. 91 | 92 | The actual implementation is required to override :meth:`get_redirect_url`. 93 | """ 94 | template_name = 'provider/authorize.html' 95 | 96 | def get_redirect_url(self, request): 97 | """ 98 | Return a redirect to a URL where the resource owner (see :rfc:`1`) 99 | authorizes the client (also :rfc:`1`). 100 | 101 | :return: :class:`django.http.HttpResponseRedirect` 102 | 103 | """ 104 | raise NotImplementedError 105 | 106 | def validate_scopes(self, scope_list): 107 | raise NotImplementedError 108 | 109 | def handle(self, request, data): 110 | self.cache_data(request, data) 111 | 112 | if constants.ENFORCE_SECURE and not request.is_secure(): 113 | return self.render_to_response({'error': 'access_denied', 114 | 'error_description': _("A secure connection is required."), 115 | 'next': None}, 116 | status=400) 117 | 118 | scope_list = [s for s in 119 | data.get('scope', '').split(' ') if s != ''] 120 | if self.validate_scopes(scope_list): 121 | return HttpResponseRedirect(self.get_redirect_url(request)) 122 | else: 123 | return HttpResponse("Invalid scope.", status=400) 124 | 125 | def get(self, request): 126 | return self.handle(request, request.GET) 127 | 128 | def post(self, request): 129 | return self.handle(request, request.POST) 130 | 131 | 132 | class AuthorizeViewBase(AuthUtilMixin, TemplateView): 133 | """ 134 | View to handle the client authorization as outlined in :rfc:`4`. 135 | Implementation must override a set of methods: 136 | 137 | * :attr:`get_redirect_url` 138 | * :attr:`get_request_form` 139 | * :attr:`get_authorization_form` 140 | * :attr:`get_client` 141 | * :attr:`save_authorization` 142 | 143 | :attr:`Authorize` renders the ``provider/authorize.html`` template to 144 | display the authorization form. 145 | 146 | On successful authorization, it redirects the user back to the defined 147 | client callback as defined in :rfc:`4.1.2`. 148 | 149 | On authorization fail :attr:`Authorize` displays an error message to the 150 | user with a modified redirect URL to the callback including the error 151 | and possibly description of the error as defined in :rfc:`4.1.2.1`. 152 | """ 153 | template_name = 'provider/authorize.html' 154 | 155 | def get_redirect_url(self, request): 156 | """ 157 | :return: ``str`` - The client URL to display in the template after 158 | authorization succeeded or failed. 159 | """ 160 | raise NotImplementedError 161 | 162 | def get_request_form(self, client, data): 163 | """ 164 | Return a form that is capable of validating the request data captured 165 | by the :class:`Capture` view. 166 | The form must accept a keyword argument ``client``. 167 | """ 168 | raise NotImplementedError 169 | 170 | def get_authorization_form(self, request, client, data, client_data): 171 | """ 172 | Return a form that is capable of authorizing the client to the resource 173 | owner. 174 | 175 | :return: :attr:`django.forms.Form` 176 | """ 177 | raise NotImplementedError 178 | 179 | def get_client(self, client_id): 180 | """ 181 | Return a client object from a given client identifier. Return ``None`` 182 | if no client is found. An error will be displayed to the resource owner 183 | and presented to the client upon the final redirect. 184 | """ 185 | raise NotImplementedError 186 | 187 | def save_authorization(self, request, client, form, client_data): 188 | """ 189 | Save the authorization that the user granted to the client, involving 190 | the creation of a time limited authorization code as outlined in 191 | :rfc:`4.1.2`. 192 | 193 | Should return ``None`` in case authorization is not granted. 194 | Should return a string representing the authorization code grant. 195 | 196 | :return: ``None``, ``str`` 197 | """ 198 | raise NotImplementedError 199 | 200 | def has_authorization(self, request, client, scope_list): 201 | """ 202 | Check to see if there is a previous authorization request with the 203 | requested scope permissions. 204 | 205 | :param request: 206 | :param client: 207 | :param scope_list: 208 | :return: ``False``, ``AuthorizedClient`` 209 | """ 210 | return False 211 | 212 | def _validate_client(self, request, data): 213 | """ 214 | :return: ``tuple`` - ``(client or False, data or error)`` 215 | """ 216 | client = self.get_client(data.get('client_id')) 217 | 218 | if client is None: 219 | raise OAuthError({ 220 | 'error': 'unauthorized_client', 221 | 'error_description': _("An unauthorized client tried to access" 222 | " your resources.") 223 | }) 224 | 225 | form = self.get_request_form(client, data) 226 | 227 | if not form.is_valid(): 228 | raise OAuthError(form.errors) 229 | 230 | return client, form.cleaned_data 231 | 232 | def error_response(self, request, error, **kwargs): 233 | """ 234 | Return an error to be displayed to the resource owner if anything goes 235 | awry. Errors can include invalid clients, authorization denials and 236 | other edge cases such as a wrong ``redirect_uri`` in the authorization 237 | request. 238 | 239 | :param request: :attr:`django.http.HttpRequest` 240 | :param error: ``dict`` 241 | The different types of errors are outlined in :rfc:`4.2.2.1` 242 | """ 243 | ctx = {} 244 | ctx.update(error) 245 | 246 | # If we got a malicious redirect_uri or client_id, remove all the 247 | # cached data and tell the resource owner. We will *not* redirect back 248 | # to the URL. 249 | 250 | if error.get('error') in ['redirect_uri', 'unauthorized_client']: 251 | ctx.update(next='/') 252 | return self.render_to_response(ctx, **kwargs) 253 | 254 | ctx.update(next=self.get_redirect_url(request)) 255 | 256 | return self.render_to_response(ctx, **kwargs) 257 | 258 | def handle(self, request, post_data=None): 259 | data = self.get_data(request) 260 | 261 | if data is None: 262 | return self.error_response(request, { 263 | 'error': 'expired_authorization', 264 | 'error_description': _('Authorization session has expired.')}) 265 | 266 | try: 267 | client, data = self._validate_client(request, data) 268 | except OAuthError as e: 269 | return self.error_response(request, e.args[0], status=400) 270 | 271 | scope_list = [s.name for s in 272 | data.get('scope', [])] 273 | if self.has_authorization(request, client, scope_list): 274 | post_data = { 275 | 'scope': scope_list, 276 | 'authorize': u'Authorize', 277 | } 278 | 279 | authorization_form = self.get_authorization_form(request, client, 280 | post_data, data) 281 | 282 | if not authorization_form.is_bound or not authorization_form.is_valid(): 283 | return self.render_to_response({ 284 | 'client': client, 285 | 'form': authorization_form, 286 | 'oauth_data': data, 287 | }) 288 | 289 | code = self.save_authorization(request, client, 290 | authorization_form, data) 291 | 292 | # be sure to serialize any objects that aren't natively json 293 | # serializable because these values are stored as session data 294 | data['scope'] = scope_list 295 | self.cache_data(request, data) 296 | self.cache_data(request, code, "code") 297 | self.cache_data(request, client.pk, "client_pk") 298 | 299 | return HttpResponseRedirect(self.get_redirect_url(request)) 300 | 301 | def get(self, request): 302 | return self.handle(request, None) 303 | 304 | def post(self, request): 305 | return self.handle(request, request.POST) 306 | 307 | 308 | class RedirectViewBase(AuthUtilMixin, View): 309 | """ 310 | Redirect the user back to the client with the right query parameters set. 311 | This can be either parameters indicating success or parameters indicating 312 | an error. 313 | """ 314 | 315 | def error_response(self, error, mimetype='application/json', status=400, 316 | **kwargs): 317 | """ 318 | Return an error response to the client with default status code of 319 | *400* stating the error as outlined in :rfc:`5.2`. 320 | """ 321 | return HttpResponse(json.dumps(error), content_type=mimetype, 322 | status=status, **kwargs) 323 | 324 | def get(self, request): 325 | data = self.get_data(request) 326 | code = self.get_data(request, "code") 327 | error = self.get_data(request, "error") 328 | client_pk = self.get_data(request, "client_pk") 329 | 330 | client = Client.objects.get(pk=client_pk) 331 | 332 | # this is an edge case that is caused by making a request with no data 333 | # it should only happen if this view is called manually, out of the 334 | # normal capture-authorize-redirect flow. 335 | if data is None or client is None: 336 | return self.error_response({ 337 | 'error': 'invalid_data', 338 | 'error_description': _('Data has not been captured')}) 339 | 340 | redirect_uri = data.get('redirect_uri', None) or client.redirect_uri 341 | 342 | parsed = urlparse(redirect_uri) 343 | 344 | query = QueryDict('', mutable=True) 345 | 346 | if 'state' in data: 347 | query['state'] = data['state'] 348 | 349 | if error is not None: 350 | query.update(error) 351 | elif code is None: 352 | query['error'] = 'access_denied' 353 | else: 354 | query['code'] = code 355 | 356 | parsed = parsed[:4] + (query.urlencode(), '') 357 | 358 | redirect_uri = ParseResult(*parsed).geturl() 359 | 360 | self.clear_data(request) 361 | 362 | return HttpResponseRedirect(redirect_uri) 363 | 364 | 365 | class AccessTokenViewBase(AuthUtilMixin, TemplateView): 366 | """ 367 | :attr:`AccessToken` handles creation and refreshing of access tokens. 368 | 369 | Implementations must implement a number of methods: 370 | 371 | * :attr:`get_authorization_code_grant` 372 | * :attr:`get_refresh_token_grant` 373 | * :attr:`get_password_grant` 374 | * :attr:`get_access_token` 375 | * :attr:`create_access_token` 376 | * :attr:`create_refresh_token` 377 | * :attr:`invalidate_grant` 378 | * :attr:`invalidate_access_token` 379 | * :attr:`invalidate_refresh_token` 380 | 381 | The default implementation supports the grant types defined in 382 | :attr:`grant_types`. 383 | 384 | According to :rfc:`4.4.2` this endpoint too must support secure 385 | communication. For strict enforcement of secure communication at 386 | application level set :attr:`settings.OAUTH_ENFORCE_SECURE` to ``True``. 387 | 388 | According to :rfc:`3.2` we can only accept POST requests. 389 | 390 | Returns with a status code of *400* in case of errors. *200* in case of 391 | success. 392 | """ 393 | 394 | authentication = () 395 | """ 396 | Authentication backends used to authenticate a particular client. 397 | """ 398 | 399 | grant_types = ['authorization_code', 'refresh_token', 'password'] 400 | """ 401 | The default grant types supported by this view. 402 | """ 403 | 404 | def get_authorization_code_grant(self, request, data, client): 405 | """ 406 | Return the grant associated with this request or an error dict. 407 | 408 | :return: ``tuple`` - ``(True or False, grant or error_dict)`` 409 | """ 410 | raise NotImplementedError 411 | 412 | def get_refresh_token_grant(self, request, data, client): 413 | """ 414 | Return the refresh token associated with this request or an error dict. 415 | 416 | :return: ``tuple`` - ``(True or False, token or error_dict)`` 417 | """ 418 | raise NotImplementedError 419 | 420 | def get_password_grant(self, request, data, client): 421 | """ 422 | Return a user associated with this request or an error dict. 423 | 424 | :return: ``tuple`` - ``(True or False, user or error_dict)`` 425 | """ 426 | raise NotImplementedError 427 | 428 | def get_access_token(self, request, user, scope, client): 429 | """ 430 | Override to handle fetching of an existing access token. 431 | 432 | :return: ``object`` - Access token 433 | """ 434 | raise NotImplementedError 435 | 436 | def create_access_token(self, request, user, scope, client): 437 | """ 438 | Override to handle access token creation. 439 | 440 | :return: ``object`` - Access token 441 | """ 442 | raise NotImplementedError 443 | 444 | def create_refresh_token(self, request, user, scope, access_token, client): 445 | """ 446 | Override to handle refresh token creation. 447 | 448 | :return: ``object`` - Refresh token 449 | """ 450 | raise NotImplementedError 451 | 452 | def invalidate_grant(self, grant): 453 | """ 454 | Override to handle grant invalidation. A grant is invalidated right 455 | after creating an access token from it. 456 | 457 | :return None: 458 | """ 459 | raise NotImplementedError 460 | 461 | def invalidate_refresh_token(self, refresh_token): 462 | """ 463 | Override to handle refresh token invalidation. When requesting a new 464 | access token from a refresh token, the old one is *always* invalidated. 465 | 466 | :return None: 467 | """ 468 | raise NotImplementedError 469 | 470 | def invalidate_access_token(self, access_token): 471 | """ 472 | Override to handle access token invalidation. When a new access token 473 | is created from a refresh token, the old one is *always* invalidated. 474 | 475 | :return None: 476 | """ 477 | raise NotImplementedError 478 | 479 | def error_response(self, error, mimetype='application/json', status=400, 480 | **kwargs): 481 | """ 482 | Return an error response to the client with default status code of 483 | *400* stating the error as outlined in :rfc:`5.2`. 484 | """ 485 | return HttpResponse(json.dumps(error), content_type=mimetype, 486 | status=status, **kwargs) 487 | 488 | def access_token_response(self, access_token): 489 | """ 490 | Returns a successful response after creating the access token 491 | as defined in :rfc:`5.1`. 492 | """ 493 | 494 | response_data = { 495 | 'access_token': access_token.token, 496 | 'token_type': constants.TOKEN_TYPE, 497 | 'expires_in': access_token.get_expire_delta(), 498 | 'scope': access_token.get_scope_string(), 499 | } 500 | 501 | # Not all access_tokens are given a refresh_token 502 | # (for example, public clients doing password auth) 503 | try: 504 | rt = access_token.refresh_token 505 | response_data['refresh_token'] = rt.token 506 | except ObjectDoesNotExist: 507 | pass 508 | 509 | return HttpResponse( 510 | json.dumps(response_data), content_type='application/json' 511 | ) 512 | 513 | def authorization_code(self, request, data, client): 514 | """ 515 | Handle ``grant_type=authorization_code`` requests as defined in 516 | :rfc:`4.1.3`. 517 | """ 518 | grant = self.get_authorization_code_grant(request, request.POST, 519 | client) 520 | at = self.create_access_token(request, grant.user, 521 | list(grant.scope.all()), client) 522 | 523 | suppress_refresh_token = False 524 | if client.client_type == constants.PUBLIC and client.allow_public_token: 525 | if not request.POST.get('client_secret'): 526 | suppress_refresh_token = True 527 | 528 | if not suppress_refresh_token: 529 | rt = self.create_refresh_token(request, grant.user, 530 | list(grant.scope.all()), at, client) 531 | 532 | self.invalidate_grant(grant) 533 | 534 | return self.access_token_response(at) 535 | 536 | def refresh_token(self, request, data, client): 537 | """ 538 | Handle ``grant_type=refresh_token`` requests as defined in :rfc:`6`. 539 | """ 540 | rt = self.get_refresh_token_grant(request, data, client) 541 | 542 | token_scope = list(rt.access_token.scope.all()) 543 | 544 | # this must be called first in case we need to purge expired tokens 545 | self.invalidate_refresh_token(rt) 546 | self.invalidate_access_token(rt.access_token) 547 | 548 | at = self.create_access_token(request, rt.user, 549 | token_scope, 550 | client) 551 | rt = self.create_refresh_token(request, at.user, 552 | at.scope.all(), at, client) 553 | 554 | return self.access_token_response(at) 555 | 556 | def password(self, request, data, client): 557 | """ 558 | Handle ``grant_type=password`` requests as defined in :rfc:`4.3`. 559 | """ 560 | 561 | data = self.get_password_grant(request, data, client) 562 | user = data.get('user') 563 | scope = data.get('scope') 564 | 565 | at = self.create_access_token(request, user, scope, client) 566 | # Public clients don't get refresh tokens 567 | if client.client_type != constants.PUBLIC: 568 | rt = self.create_refresh_token(request, user, scope, at, client) 569 | 570 | return self.access_token_response(at) 571 | 572 | def get_handler(self, grant_type): 573 | """ 574 | Return a function or method that is capable handling the ``grant_type`` 575 | requested by the client or return ``None`` to indicate that this type 576 | of grant type is not supported, resulting in an error response. 577 | """ 578 | if grant_type == 'authorization_code': 579 | return self.authorization_code 580 | elif grant_type == 'refresh_token': 581 | return self.refresh_token 582 | elif grant_type == 'password': 583 | return self.password 584 | return None 585 | 586 | def get(self, request): 587 | """ 588 | As per :rfc:`3.2` the token endpoint *only* supports POST requests. 589 | Returns an error response. 590 | """ 591 | return self.error_response({ 592 | 'error': 'invalid_request', 593 | 'error_description': _("Only POST requests allowed.")}) 594 | 595 | def post(self, request): 596 | """ 597 | As per :rfc:`3.2` the token endpoint *only* supports POST requests. 598 | """ 599 | if constants.ENFORCE_SECURE and not request.is_secure(): 600 | return self.error_response({ 601 | 'error': 'invalid_request', 602 | 'error_description': _("A secure connection is required.")}) 603 | 604 | if not 'grant_type' in request.POST: 605 | return self.error_response({ 606 | 'error': 'invalid_request', 607 | 'error_description': _("No 'grant_type' included in the " 608 | "request.")}) 609 | 610 | grant_type = request.POST['grant_type'] 611 | 612 | if grant_type not in self.grant_types: 613 | return self.error_response({'error': 'unsupported_grant_type'}) 614 | 615 | client = self.authenticate(request) 616 | 617 | if client is None: 618 | return self.error_response({'error': 'invalid_client'}) 619 | 620 | handler = self.get_handler(grant_type) 621 | 622 | try: 623 | return handler(request, request.POST, client) 624 | except OAuthError as e: 625 | return self.error_response(e.args[0]) 626 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==3.2 2 | shortuuid==1.0.11 3 | six>=0.16.0 4 | sqlparse>=0.4.3 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | import provider 5 | 6 | setup( 7 | name='django-oauth2', 8 | version=provider.__version__, 9 | description='Provide OAuth2 access to your app (fork of django-oauth2-provider)', 10 | long_description=open('README.rst').read(), 11 | author='Shaun Kruger', 12 | author_email='shaun.kruger@gmail.com', 13 | url = 'https://github.com/stormsherpa/django-oauth2-provider', 14 | packages=find_packages(exclude=('tests*',)), 15 | license='The MIT License: http://www.opensource.org/licenses/mit-license.php', 16 | platforms='all', 17 | classifiers=[ 18 | 'Environment :: Web Environment', 19 | 'Intended Audience :: Developers', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Operating System :: OS Independent', 22 | 'Programming Language :: Python', 23 | 'Framework :: Django', 24 | ], 25 | install_requires=[ 26 | "shortuuid>=1.0.11", 27 | "six>=0.16.0", 28 | "sqlparse>=0.4.3", 29 | ], 30 | include_package_data=True, 31 | zip_safe=False, 32 | ) 33 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | app_names=( provider provider.oauth2 ) 4 | 5 | python manage.py test ${app_names[@]} --traceback 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caffeinehit/django-oauth2-provider/0ac5e6ea6be196f92c4557b048e52f24d0c72af6/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | import os 3 | from django import VERSION as DJANGO_VERSION 4 | 5 | DEBUG = True 6 | 7 | ADMINS = ( 8 | ('Tester', 'test@example.com'), 9 | ) 10 | 11 | MANAGERS = ADMINS 12 | 13 | DATABASES = { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 16 | 'NAME': '%s/db.sqlite' % os.path.dirname(__file__), # Or path to database file if using sqlite3. 17 | 'USER': '', # Not used with sqlite3. 18 | 'PASSWORD': '', # Not used with sqlite3. 19 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 20 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 21 | } 22 | } 23 | 24 | 25 | SITE_ID = 1 26 | 27 | # Absolute filesystem path to the directory that will hold user-uploaded files. 28 | # Example: "/home/media/media.lawrence.com/media/" 29 | MEDIA_ROOT = '' 30 | 31 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 32 | # trailing slash. 33 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 34 | MEDIA_URL = '' 35 | 36 | # Absolute path to the directory static files should be collected to. 37 | # Don't put anything in this directory yourself; store your static files 38 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 39 | # Example: "/home/media/media.lawrence.com/static/" 40 | STATIC_ROOT = '' 41 | 42 | # URL prefix for static files. 43 | # Example: "http://media.lawrence.com/static/" 44 | STATIC_URL = '/static/' 45 | 46 | # Make this unique, and don't share it with anybody. 47 | SECRET_KEY = 'secret' 48 | 49 | ROOT_URLCONF = 'tests.urls' 50 | 51 | INSTALLED_APPS = ( 52 | 'django.contrib.auth', 53 | 'django.contrib.contenttypes', 54 | 'django.contrib.sessions', 55 | 'django.contrib.sites', 56 | 'django.contrib.messages', 57 | 'django.contrib.staticfiles', 58 | 'django.contrib.admin', 59 | 'provider', 60 | 'provider.oauth2', 61 | ) 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'DIRS': [os.path.join(os.path.dirname(__file__), 'templates')], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | # 'django.template.context_processors.debug', 71 | # 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | MIDDLEWARE = ( 80 | 'django.contrib.sessions.middleware.SessionMiddleware', 81 | 'django.middleware.common.CommonMiddleware', 82 | 'django.middleware.csrf.CsrfViewMiddleware', 83 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 84 | 'provider.oauth2.middleware.Oauth2UserMiddleware', 85 | 'django.contrib.messages.middleware.MessageMiddleware', 86 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 87 | ) 88 | 89 | AUTHENTICATION_BACKENDS = [ 90 | 'django.contrib.auth.backends.RemoteUserBackend', 91 | 'django.contrib.auth.backends.ModelBackend', 92 | ] 93 | 94 | PASSWORD_HASHERS = [ 95 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 96 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 97 | 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', 98 | 'django.contrib.auth.hashers.SHA1PasswordHasher', # Used by unit tests 99 | ] 100 | 101 | USE_TZ = True 102 | 103 | # Use DiscoverRunner on Django 1.7 and above 104 | if DJANGO_VERSION[0] == 1 and DJANGO_VERSION[1] >= 7: 105 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 106 | 107 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | {% endblock %} 3 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | url(r'^admin/', admin.site.urls), 8 | url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')), 9 | url(r'^tests/', include('provider.oauth2.tests.urls', namespace='tests')), 10 | ] 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | toxworkdir={env:TOX_WORK_DIR:.tox} 3 | downloadcache = {toxworkdir}/cache/ 4 | envlist = py{3.8,3.9,3.10}-django{3.0,3.1,3.2,4.0,4.1} 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONPATH = {toxinidir} 9 | commands = 10 | {toxinidir}/test.sh 11 | deps = 12 | 13 | [travis] 14 | python = 15 | 3.8: py3.8-django{3.0,3.1,3.2,4.0,4.1} 16 | 17 | 18 | [testenv:py3.8-django3.0] 19 | basepython = python3.8 20 | deps = Django>=3.0,<3.1 21 | {[testenv]deps} 22 | 23 | [testenv:py3.8-django3.1] 24 | basepython = python3.8 25 | deps = Django>=3.1,<3.2 26 | {[testenv]deps} 27 | 28 | [testenv:py3.8-django3.2] 29 | basepython = python3.8 30 | deps = Django>=3.2,<4.0 31 | {[testenv]deps} 32 | 33 | [testenv:py3.8-django4.0] 34 | basepython = python3.8 35 | deps = Django>=4.0,<4.1 36 | {[testenv]deps} 37 | 38 | [testenv:py3.8-django4.1] 39 | basepython = python3.8 40 | deps = Django>=4.1,<4.2 41 | {[testenv]deps} 42 | --------------------------------------------------------------------------------