diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..2ca598bbd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[{Makefile,tox.ini,setup.cfg}] +indent_style = tab + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..323a7fcff --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/ambv/black + rev: 20.8b1 + hooks: + - id: black + exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-ast + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-json + - id: check-xml + - id: check-yaml + - id: mixed-line-ending + args: ['--fix=lf'] + - repo: https://github.com/PyCQA/isort + rev: 5.6.3 + hooks: + - id: isort + exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + exclude: ^(oauth2_provider/migrations/|tests/migrations/) diff --git a/AUTHORS b/AUTHORS index ef1708d5c..4f9cd850b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,3 +32,4 @@ Stéphane Raimbault Jun Zhou David Smith Łukasz Skarżyński +Tom Evans diff --git a/docs/conf.py b/docs/conf.py index 628fb4bed..fefcff4dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,27 +32,33 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'rfc', 'm2r',] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "rfc", + "m2r", +] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Django OAuth Toolkit' -copyright = u'2013, Evonove' +project = "Django OAuth Toolkit" +copyright = "2013, Evonove" # The version info for the project you're documenting, acts as replacement for @@ -66,181 +72,176 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html -extensions.append('sphinx.ext.intersphinx') -intersphinx_mapping = {'python3': ('https://docs.python.org/3.6', None), - 'django': ('http://django.readthedocs.org/en/latest/', None)} - +extensions.append("sphinx.ext.intersphinx") +intersphinx_mapping = { + "python3": ("https://docs.python.org/3.6", None), + "django": ("http://django.readthedocs.org/en/latest/", None), +} # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -#html_theme = 'classic' +# html_theme = 'classic' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoOAuthToolkitdoc' +htmlhelp_basename = "DjangoOAuthToolkitdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'DjangoOAuthToolkit.tex', u'Django OAuth Toolkit Documentation', - u'Evonove', 'manual'), + ("index", "DjangoOAuthToolkit.tex", "Django OAuth Toolkit Documentation", "Evonove", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'djangooauthtoolkit', u'Django OAuth Toolkit Documentation', - [u'Evonove'], 1) -] +man_pages = [("index", "djangooauthtoolkit", "Django OAuth Toolkit Documentation", ["Evonove"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -249,19 +250,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'DjangoOAuthToolkit', u'Django OAuth Toolkit Documentation', - u'Evonove', 'DjangoOAuthToolkit', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "DjangoOAuthToolkit", + "Django OAuth Toolkit Documentation", + "Evonove", + "DjangoOAuthToolkit", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/contributing.rst b/docs/contributing.rst index 5d36149b0..39ed1a427 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -24,6 +24,34 @@ You can find the list of bugs, enhancements and feature requests on the `issue tracker `_. If you want to fix an issue, pick up one and add a comment stating you're working on it. +Code Style +========== + +The project uses `flake8 `_ for linting, +`black `_ for formatting the code, +`isort `_ for formatting and sorting imports, +and `pre-commit `_ for checking/fixing commits for +correctness before they are made. + +You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will +take care of installing ``flake8``, ``black`` and ``isort``. + +After cloning your repository, go into it and run:: + + pre-commit install + +to install the hooks. On the next commit that you make, ``pre-commit`` will +download and install the necessary hooks (a one off task). If anything in the +commit would fail the hooks, the commit will be abandoned. For ``black`` and +``isort``, any necessary changes will be made automatically, but not staged. +Review the changes, and then re-stage and commit again. + +Using ``pre-commit`` ensures that code that would fail in QA does not make it +into a commit in the first place, and will save you time in the long run. You +can also (largely) stop worrying about code style, although you should always +check how the code looks after ``black`` has formatted it, and think if there +is a better way to structure the code so that it is more readable. + Pull requests ============= diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 8b963d981..a2ec8501a 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,9 +1,6 @@ from django.contrib import admin -from .models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model -) +from .models import get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model class ApplicationAdmin(admin.ModelAdmin): @@ -13,12 +10,12 @@ class ApplicationAdmin(admin.ModelAdmin): "client_type": admin.HORIZONTAL, "authorization_grant_type": admin.VERTICAL, } - raw_id_fields = ("user", ) + raw_id_fields = ("user",) class GrantAdmin(admin.ModelAdmin): list_display = ("code", "application", "user", "expires") - raw_id_fields = ("user", ) + raw_id_fields = ("user",) class AccessTokenAdmin(admin.ModelAdmin): diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py index a004c1872..b54f42220 100644 --- a/oauth2_provider/contrib/rest_framework/__init__.py +++ b/oauth2_provider/contrib/rest_framework/__init__.py @@ -1,6 +1,9 @@ # flake8: noqa from .authentication import OAuth2Authentication from .permissions import ( - TokenHasScope, TokenHasReadWriteScope, TokenMatchesOASRequirements, - TokenHasResourceScope, IsAuthenticatedOrTokenHasScope + IsAuthenticatedOrTokenHasScope, + TokenHasReadWriteScope, + TokenHasResourceScope, + TokenHasScope, + TokenMatchesOASRequirements, ) diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 228361967..53087f756 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -9,16 +9,14 @@ class OAuth2Authentication(BaseAuthentication): """ OAuth 2 authentication backend using `django-oauth-toolkit` """ + www_authenticate_realm = "api" def _dict_to_string(self, my_dict): """ Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). """ - return ",".join([ - '{k}="{v}"'.format(k=k, v=v) - for k, v in my_dict.items() - ]) + return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()]) def authenticate(self, request): """ @@ -36,9 +34,11 @@ def authenticate_header(self, request): """ Bearer is the only finalized type currently """ - www_authenticate_attributes = OrderedDict([ - ("realm", self.www_authenticate_realm,), - ]) + www_authenticate_attributes = OrderedDict( + [ + ("realm", self.www_authenticate_realm), + ] + ) oauth2_error = getattr(request, "oauth2_error", {}) www_authenticate_attributes.update(oauth2_error) return "Bearer {attributes}".format( diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 7ba1c5c71..1050bf751 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -2,9 +2,7 @@ from django.core.exceptions import ImproperlyConfigured from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import ( - SAFE_METHODS, BasePermission, IsAuthenticated -) +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from ...settings import oauth2_settings from .authentication import OAuth2Authentication @@ -33,10 +31,10 @@ def has_permission(self, request, view): # Provide information about required scope? include_required_scope = ( - oauth2_settings.ERROR_RESPONSE_WITH_SCOPES and - required_scopes and - not token.is_expired() and - not token.allow_scopes(required_scopes) + oauth2_settings.ERROR_RESPONSE_WITH_SCOPES + and required_scopes + and not token.is_expired() + and not token.allow_scopes(required_scopes) ) if include_required_scope: @@ -47,9 +45,11 @@ def has_permission(self, request, view): return False - assert False, ("TokenHasScope requires the" - "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " - "class to be used.") + assert False, ( + "TokenHasScope requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) def get_scopes(self, request, view): try: @@ -96,9 +96,7 @@ def get_scopes(self, request, view): else: scope_type = oauth2_settings.WRITE_SCOPE - required_scopes = [ - "{}:{}".format(scope, scope_type) for scope in view_scopes - ] + required_scopes = ["{}:{}".format(scope, scope_type) for scope in view_scopes] return required_scopes @@ -113,6 +111,7 @@ class IsAuthenticatedOrTokenHasScope(BasePermission): the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ + def has_permission(self, request, view): is_authenticated = IsAuthenticated().has_permission(request, view) oauth2authenticated = False @@ -155,8 +154,11 @@ def has_permission(self, request, view): m = request.method.upper() if m in required_alternate_scopes: - log.debug("Required scopes alternatives to access resource: {0}" - .format(required_alternate_scopes[m])) + log.debug( + "Required scopes alternatives to access resource: {0}".format( + required_alternate_scopes[m] + ) + ) for alt in required_alternate_scopes[m]: if token.is_valid(alt): return True @@ -165,9 +167,11 @@ def has_permission(self, request, view): log.warning("no scope alternates defined for method {0}".format(m)) return False - assert False, ("TokenMatchesOASRequirements requires the" - "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " - "class to be used.") + assert False, ( + "TokenMatchesOASRequirements requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) def get_required_alternate_scopes(self, request, view): try: @@ -175,4 +179,5 @@ def get_required_alternate_scopes(self, request, view): except AttributeError: raise ImproperlyConfigured( "TokenMatchesOASRequirements requires the view to" - " define the required_alternate_scopes attribute") + " define the required_alternate_scopes attribute" + ) diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index d4b7085aa..0ab26ddb4 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -33,7 +33,9 @@ def _validate(request, *args, **kwargs): request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() + return _validate + return decorator @@ -62,8 +64,7 @@ def _validate(request, *args, **kwargs): if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "rw_protected_resource decorator requires following scopes {0}" - " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format( - read_write_scopes) + " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) ) # Check if method is safe @@ -80,5 +81,7 @@ def _validate(request, *args, **kwargs): request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() + return _validate + return decorator diff --git a/oauth2_provider/exceptions.py b/oauth2_provider/exceptions.py index 215515500..c4208488d 100644 --- a/oauth2_provider/exceptions.py +++ b/oauth2_provider/exceptions.py @@ -2,6 +2,7 @@ class OAuthToolkitError(Exception): """ Base class for exceptions """ + def __init__(self, error=None, redirect_uri=None, *args, **kwargs): super().__init__(*args, **kwargs) self.oauthlib_error = error @@ -14,4 +15,5 @@ class FatalClientError(OAuthToolkitError): """ Class for critical errors """ + pass diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index ab5d25a7a..f72bc6e7a 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -8,6 +8,7 @@ class BaseHashGenerator: """ All generators should extend this class overriding `.hash()` method. """ + def hash(self): raise NotImplementedError() diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py index 980cb7bd4..274ed81af 100644 --- a/oauth2_provider/http.py +++ b/oauth2_provider/http.py @@ -11,6 +11,7 @@ class OAuth2ResponseRedirect(HttpResponse): Works like django.http.HttpResponseRedirect but we customize it to give us more flexibility on allowed scheme validation. """ + status_code = 302 def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): @@ -28,6 +29,4 @@ def validate_redirect(self, redirect_to): if not parsed.scheme: raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") if parsed.scheme not in self.allowed_schemes: - raise DisallowedRedirect( - "Redirect to scheme {!r} is not permitted".format(parsed.scheme) - ) + raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme)) diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 95cb2d865..92c4ae46b 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -72,15 +72,10 @@ def handle(self, *args, **options): try: new_application.full_clean() except ValidationError as exc: - errors = "\n ".join(["- " + err_key + ": " + str(err_value) for err_key, - err_value in exc.message_dict.items()]) - self.stdout.write( - self.style.ERROR( - "Please correct the following errors:\n %s" % errors - ) + errors = "\n ".join( + ["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()] ) + self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) else: new_application.save() - self.stdout.write( - self.style.SUCCESS("New application created successfully") - ) + self.stdout.write(self.style.SUCCESS("New application created successfully")) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 5676bc0c5..77542d35f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -39,6 +39,7 @@ class AbstractApplication(models.Model): the registration process as described in :rfc:`2.2` * :attr:`name` Friendly name for the Application """ + CLIENT_CONFIDENTIAL = "confidential" CLIENT_PUBLIC = "public" CLIENT_TYPES = ( @@ -58,22 +59,21 @@ class AbstractApplication(models.Model): ) id = models.BigAutoField(primary_key=True) - client_id = models.CharField( - max_length=100, unique=True, default=generate_client_id, db_index=True - ) + client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", - null=True, blank=True, on_delete=models.CASCADE + null=True, + blank=True, + on_delete=models.CASCADE, ) redirect_uris = models.TextField( - blank=True, help_text=_("Allowed URIs list, space separated"), + blank=True, + help_text=_("Allowed URIs list, space separated"), ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) - authorization_grant_type = models.CharField( - max_length=32, choices=GRANT_TYPES - ) + authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) client_secret = models.CharField( max_length=255, blank=True, default=generate_client_secret, db_index=True ) @@ -115,9 +115,11 @@ def redirect_uri_allowed(self, uri): for allowed_uri in self.redirect_uris.split(): parsed_allowed_uri = urlparse(allowed_uri) - if (parsed_allowed_uri.scheme == parsed_uri.scheme and - parsed_allowed_uri.netloc == parsed_uri.netloc and - parsed_allowed_uri.path == parsed_uri.path): + if ( + parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.netloc == parsed_uri.netloc + and parsed_allowed_uri.path == parsed_uri.path + ): aqs_set = set(parse_qsl(parsed_allowed_uri.query)) @@ -143,14 +145,14 @@ def clean(self): validator(uri) scheme = urlparse(uri).scheme if scheme not in allowed_schemes: - raise ValidationError(_( - "Unauthorized redirect scheme: {scheme}" - ).format(scheme=scheme)) + raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) elif self.authorization_grant_type in grant_types: - raise ValidationError(_( - "redirect_uris cannot be empty with grant_type {grant_type}" - ).format(grant_type=self.authorization_grant_type)) + raise ValidationError( + _("redirect_uris cannot be empty with grant_type {grant_type}").format( + grant_type=self.authorization_grant_type + ) + ) def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) @@ -206,22 +208,17 @@ class AbstractGrant(models.Model): * :attr:`code_challenge` PKCE code challenge * :attr:`code_challenge_method` PKCE code challenge transform algorithm """ + CODE_CHALLENGE_PLAIN = "plain" CODE_CHALLENGE_S256 = "S256" - CODE_CHALLENGE_METHODS = ( - (CODE_CHALLENGE_PLAIN, "plain"), - (CODE_CHALLENGE_S256, "S256") - ) + CODE_CHALLENGE_METHODS = ((CODE_CHALLENGE_PLAIN, "plain"), (CODE_CHALLENGE_S256, "S256")) id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) code = models.CharField(max_length=255, unique=True) # code comes from oauthlib - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE - ) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) expires = models.DateTimeField() redirect_uri = models.CharField(max_length=255) scope = models.TextField(blank=True) @@ -231,7 +228,8 @@ class AbstractGrant(models.Model): code_challenge = models.CharField(max_length=128, blank=True, default="") code_challenge_method = models.CharField( - max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS) + max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS + ) def is_expired(self): """ @@ -271,19 +269,32 @@ class AbstractAccessToken(models.Model): * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", ) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField - oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="refreshed_access_token" + oauth2_settings.REFRESH_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refreshed_access_token", + ) + token = models.CharField( + max_length=255, + unique=True, ) - token = models.CharField(max_length=255, unique=True, ) application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, + oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, ) expires = models.DateTimeField() scope = models.TextField(blank=True) @@ -364,17 +375,19 @@ class AbstractRefreshToken(models.Model): bounded to * :attr:`revoked` Timestamp of when this refresh token was revoked """ + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) token = models.CharField(max_length=255) - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) access_token = models.OneToOneField( - oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="refresh_token" + oauth2_settings.ACCESS_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refresh_token", ) created = models.DateTimeField(auto_now_add=True) @@ -388,9 +401,11 @@ def revoke(self): access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() with transaction.atomic(): - self = refresh_token_model.objects.filter( - pk=self.pk, revoked__isnull=True - ).select_for_update().first() + self = ( + refresh_token_model.objects.filter(pk=self.pk, revoked__isnull=True) + .select_for_update() + .first() + ) if not self: return @@ -407,7 +422,10 @@ def __str__(self): class Meta: abstract = True - unique_together = ("token", "revoked",) + unique_together = ( + "token", + "revoked", + ) class RefreshToken(AbstractRefreshToken): @@ -466,13 +484,9 @@ def clear_expired(): revoked.delete() expired.delete() else: - logger.info("refresh_expire_at is %s. No refresh tokens deleted.", - refresh_expire_at) + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) - access_tokens = access_token_model.objects.filter( - refresh_token__isnull=True, - expires__lt=now - ) + access_tokens = access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now) grants = grant_model.objects.filter(expires__lt=now) logger.info("%s Expired access tokens to be deleted", access_tokens.count()) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 6d8e68a2c..34b1c62cd 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -24,9 +24,7 @@ def __init__(self, server=None): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() server_kwargs = oauth2_settings.server_kwargs - self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS( - validator, **server_kwargs - ) + self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) def _get_escaped_full_path(self, request): """ @@ -96,7 +94,8 @@ def validate_authorization_request(self, request): try: uri, http_method, body, headers = self._extract_params(request) scopes, credentials = self.server.validate_authorization_request( - uri, http_method=http_method, body=body, headers=headers) + uri, http_method=http_method, body=body, headers=headers + ) return scopes, credentials except oauth2.FatalClientError as error: @@ -117,24 +116,22 @@ def create_authorization_response(self, request, scopes, credentials, allow): """ try: if not allow: - raise oauth2.AccessDeniedError( - state=credentials.get("state", None)) + raise oauth2.AccessDeniedError(state=credentials.get("state", None)) # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) + uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials + ) uri = headers.get("Location", None) return uri, headers, body, status except oauth2.FatalClientError as error: - raise FatalClientError( - error=error, redirect_uri=credentials["redirect_uri"]) + raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) except oauth2.OAuth2Error as error: - raise OAuthToolkitError( - error=error, redirect_uri=credentials["redirect_uri"]) + raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) def create_token_response(self, request): """ @@ -145,8 +142,9 @@ def create_token_response(self, request): uri, http_method, body, headers = self._extract_params(request) extra_credentials = self._get_extra_credentials(request) - headers, body, status = self.server.create_token_response(uri, http_method, body, - headers, extra_credentials) + headers, body, status = self.server.create_token_response( + uri, http_method, body, headers, extra_credentials + ) uri = headers.get("Location", None) return uri, headers, body, status @@ -160,8 +158,7 @@ def create_revocation_response(self, request): """ uri, http_method, body, headers = self._extract_params(request) - headers, body, status = self.server.create_revocation_response( - uri, http_method, body, headers) + headers, body, status = self.server.create_revocation_response(uri, http_method, body, headers) uri = headers.get("Location", None) return uri, headers, body, status @@ -175,8 +172,7 @@ def verify_request(self, request, scopes): """ uri, http_method, body, headers = self._extract_params(request) - valid, r = self.server.verify_request( - uri, http_method, body, headers, scopes=scopes) + valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes) return valid, r def authenticate_client(self, request): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 515353d6f..de707bb21 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -19,8 +19,11 @@ from .exceptions import FatalClientError from .models import ( - AbstractApplication, get_access_token_model, - get_application_model, get_grant_model, get_refresh_token_model + AbstractApplication, + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -29,14 +32,14 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), - "password": (AbstractApplication.GRANT_PASSWORD, ), - "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), + "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE,), + "password": (AbstractApplication.GRANT_PASSWORD,), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, - ) + ), } Application = get_application_model() @@ -91,10 +94,7 @@ def _authenticate_basic_auth(self, request): try: auth_string_decoded = b64_decoded.decode(encoding) except UnicodeDecodeError: - log.debug( - "Failed basic auth: %r can't be decoded as unicode by %r", - auth_string, encoding - ) + log.debug("Failed basic auth: %r can't be decoded as unicode by %r", auth_string, encoding) return False try: @@ -162,25 +162,33 @@ def _load_application(self, client_id, request): def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token is invalid."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token is invalid.")), + ] + ) elif access_token.is_expired(): - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token has expired."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token has expired.")), + ] + ) elif not access_token.allow_scopes(scopes): - error = OrderedDict([ - ("error", "insufficient_scope", ), - ("error_description", _("The access token is valid but does not have enough scope."), ), - ]) + error = OrderedDict( + [ + ("error", "insufficient_scope"), + ("error_description", _("The access token is valid but does not have enough scope.")), + ] + ) else: log.warning("OAuth2 access token is invalid for an unknown reason.") - error = OrderedDict([ - ("error", "invalid_token", ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ] + ) request.oauth2_error = error return request @@ -270,7 +278,7 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( - self, token, introspection_url, introspection_token, introspection_credentials + self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL @@ -297,20 +305,18 @@ def _get_token_from_authentication_server( headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} try: - response = requests.post( - introspection_url, - data={"token": token}, headers=headers - ) + response = requests.post(introspection_url, data={"token": token}, headers=headers) except requests.exceptions.RequestException: log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) return None # Log an exception when response from auth server is not successful if response.status_code != http.client.OK: - log.exception("Introspection: Failed to get a valid response " - "from authentication server. Status code: {}, " - "Reason: {}.".format(response.status_code, - response.reason)) + log.exception( + "Introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, response.reason) + ) return None try: @@ -348,7 +354,8 @@ def _get_token_from_authentication_server( "application": None, "scope": scope, "expires": expires, - }) + }, + ) return access_token @@ -372,10 +379,7 @@ def validate_bearer_token(self, token, scopes, request): if not access_token or not access_token.is_valid(scopes): if introspection_url and (introspection_token or introspection_credentials): access_token = self._get_token_from_authentication_server( - token, - introspection_url, - introspection_token, - introspection_credentials + token, introspection_url, introspection_token, introspection_credentials ) if access_token and access_token.is_valid(scopes): @@ -406,7 +410,7 @@ def validate_grant_type(self, client_id, grant_type, client, request, *args, **k """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ - assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration + assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): @@ -477,9 +481,12 @@ def save_bearer_token(self, token, request, *args, **kwargs): # expires_in is passed to Server on initialization # custom server class can have logic to override this - expires = timezone.now() + timedelta(seconds=token.get( - "expires_in", oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, - )) + expires = timezone.now() + timedelta( + seconds=token.get( + "expires_in", + oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + ) + ) if request.grant_type == "client_credentials": request.user = None @@ -497,9 +504,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so - if not self.rotate_refresh_token(request) and \ - isinstance(refresh_token_instance, RefreshToken) and \ - refresh_token_instance.access_token: + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk @@ -551,9 +560,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = RefreshToken.objects.filter( - access_token=previous_access_token - ).first().token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token).first().token + ) token["scope"] = previous_access_token.scope # No refresh token should be created, just access token @@ -582,15 +591,12 @@ def _create_authorization_code(self, request, code, expires=None): redirect_uri=request.redirect_uri, scope=" ".join(request.scopes), code_challenge=request.code_challenge or "", - code_challenge_method=request.code_challenge_method or "" + code_challenge_method=request.code_challenge_method or "", ) def _create_refresh_token(self, request, refresh_token_code, access_token): return RefreshToken.objects.create( - user=request.user, - token=refresh_token_code, - application=request.client, - access_token=access_token + user=request.user, token=refresh_token_code, application=request.client, access_token=access_token ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -643,13 +649,13 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs """ null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta( - seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS - ) + revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) + ) + rt = ( + RefreshToken.objects.filter(null_or_recent, token=refresh_token) + .select_related("access_token") + .first() ) - rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( - "access_token" - ).first() if not rt: return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 0135da8b7..42c08b676 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -55,19 +55,16 @@ "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], - # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, - # Whether or not PKCE is required - "PKCE_REQUIRED": False + "PKCE_REQUIRED": False, } # List of settings that cannot be empty diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4cf6d4c6d..c7ae526f0 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -23,8 +23,11 @@ re_path(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path(r"^authorized_tokens/(?P[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), - name="authorized-token-delete"), + re_path( + r"^authorized_tokens/(?P[\w-]+)/delete/$", + views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete", + ), ] diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index f3f82102c..6c8fa3839 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -10,12 +10,9 @@ class URIValidator(URLValidator): scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://" dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(?=3.1.0 - m2r>=0.2.1 +deps = + sphinx<3 + oauthlib>=3.1.0 + m2r>=0.2.1 [testenv:py37-flake8] skip_install = True @@ -53,18 +56,19 @@ deps = flake8 flake8-isort flake8-quotes + flake8-black [testenv:install] deps = - twine - setuptools>=39.0 - wheel + twine + setuptools>=39.0 + wheel whitelist_externals= - rm + rm commands = - rm -rf dist - python setup.py sdist bdist_wheel - twine upload dist/* + rm -rf dist + python setup.py sdist bdist_wheel + twine upload dist/* [coverage:run] @@ -76,12 +80,16 @@ max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ application-import-names = oauth2_provider inline-quotes = double +extend-ignore = E203, W503 [isort] -balanced_wrapping = True default_section = THIRDPARTY known_first_party = oauth2_provider -line_length = 80 +line_length = 110 lines_after_imports = 2 -multi_line_output = 5 -skip = oauth2_provider/migrations/, .tox/ +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +skip = oauth2_provider/migrations/, .tox/, tests/migrations/