Add pep8 compliance and checker

This commit is contained in:
Philippe Savoie 2017-08-08 15:41:42 -07:00 committed by Phil Savoie
parent f78e2be3c5
commit 5dcd6a10b0
33 changed files with 365 additions and 231 deletions

View file

@ -12,18 +12,18 @@
# All configuration values have a default; values that are commented out # All configuration values have a default; values that are commented out
# serve to show the default. # serve to show the default.
import sys # import sys
import os # import os
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.')) # sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here. # 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 # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@ -38,7 +38,7 @@ templates_path = ['_templates']
# source_suffix = ['.rst'] # source_suffix = ['.rst']
# The encoding of source files. # The encoding of source files.
#source_encoding = 'utf-8-sig' # source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = 'index'
@ -66,9 +66,9 @@ language = None
# There are two options for replacing |today|: either, you set today to some # There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used: # non-false value, then it is used:
#today = '' # today = ''
# Else, today_fmt is used as the format for a strftime call. # 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
@ -76,27 +76,27 @@ exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all # The reST default role (used for this markup: `text`) to use for all
# documents. # documents.
#default_role = None # default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text. # 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 # If true, the current module name will be prepended to all description
# unit titles (such as .. function::). # unit titles (such as .. function::).
#add_module_names = True # add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the # If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default. # output. They are ignored by default.
#show_authors = False # show_authors = False
# The name of the Pygments (syntax highlighting) style to use. # 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. # 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. # If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False # keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing. # If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False todo_include_todos = False
@ -111,26 +111,26 @@ html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme # 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 # further. For a list of options available for each theme, see the
# documentation. # documentation.
#html_theme_options = {} # html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory. # 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 # The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation". # "<project> v<release> documentation".
#html_title = None # html_title = None
# A shorter title for the navigation bar. Default is the same as html_title. # 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 # The name of an image file (relative to this directory) to place at the top
# of the sidebar. # 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 # 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 # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large. # pixels large.
#html_favicon = None # html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here, # 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, # relative to this directory. They are copied after the builtin static files,
@ -140,62 +140,62 @@ html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or # Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied # .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation. # directly to the root of the documentation.
#html_extra_path = [] # html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format. # 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 # If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities. # typographically correct entities.
#html_use_smartypants = True # html_use_smartypants = True
# Custom sidebar templates, maps document names to template names. # 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 # Additional templates that should be rendered to pages, maps page names to
# template names. # template names.
#html_additional_pages = {} # html_additional_pages = {}
# If false, no module index is generated. # If false, no module index is generated.
#html_domain_indices = True # html_domain_indices = True
# If false, no index is generated. # 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. # 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. # 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. # 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. # 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 # If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the # contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served. # 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"). # This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None # html_file_suffix = None
# Language to be used for generating the HTML full-text search index. # Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages: # Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr'
#html_search_language = 'en' # html_search_language = 'en'
# A dictionary with options for the search language support, empty by default. # A dictionary with options for the search language support, empty by default.
# Now only 'ja' uses this config value # Now only 'ja' uses this config value
#html_search_options = {'type': 'default'} # html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that # The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used. # implements a search results scorer. If empty, the default will be used.
#html_search_scorer = 'scorer.js' # html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'django-oidc-providerdoc' htmlhelp_basename = 'django-oidc-providerdoc'
@ -203,17 +203,17 @@ htmlhelp_basename = 'django-oidc-providerdoc'
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper', # 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt'). # The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt', # 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble. # Additional stuff for the LaTeX preamble.
#'preamble': '', # 'preamble': '',
# Latex figure (float) alignment # Latex figure (float) alignment
#'figure_align': 'htbp', # 'figure_align': 'htbp',
} }
# Grouping the document tree into LaTeX files. List of tuples # Grouping the document tree into LaTeX files. List of tuples
@ -226,23 +226,23 @@ latex_documents = [
# The name of an image file (relative to this directory) to place at the top of # The name of an image file (relative to this directory) to place at the top of
# the title page. # the title page.
#latex_logo = None # latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts, # For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters. # not chapters.
#latex_use_parts = False # latex_use_parts = False
# If true, show page references after internal links. # If true, show page references after internal links.
#latex_show_pagerefs = False # latex_show_pagerefs = False
# If true, show URL addresses after external links. # 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. # Documents to append as an appendix to all manuals.
#latex_appendices = [] # latex_appendices = []
# If false, no module index is generated. # If false, no module index is generated.
#latex_domain_indices = True # latex_domain_indices = True
# -- Options for manual page output --------------------------------------- # -- Options for manual page output ---------------------------------------
@ -255,7 +255,7 @@ man_pages = [
] ]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
#man_show_urls = False # man_show_urls = False
# -- Options for Texinfo output ------------------------------------------- # -- Options for Texinfo output -------------------------------------------
@ -270,16 +270,16 @@ texinfo_documents = [
] ]
# Documents to append as an appendix to all manuals. # Documents to append as an appendix to all manuals.
#texinfo_appendices = [] # texinfo_appendices = []
# If false, no module index is generated. # If false, no module index is generated.
#texinfo_domain_indices = True # texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'. # 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. # If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False # texinfo_no_detailmenu = False
# -- Options for Epub output ---------------------------------------------- # -- Options for Epub output ----------------------------------------------
@ -291,62 +291,62 @@ epub_publisher = author
epub_copyright = copyright epub_copyright = copyright
# The basename for the epub file. It defaults to the project name. # The basename for the epub file. It defaults to the project name.
#epub_basename = project # epub_basename = project
# The HTML theme for the epub output. Since the default themes are not # The HTML theme for the epub output. Since the default themes are not
# optimized for small screen space, using the same theme for HTML and epub # optimized for small screen space, using the same theme for HTML and epub
# output is usually not wise. This defaults to 'epub', a theme designed to save # output is usually not wise. This defaults to 'epub', a theme designed to save
# visual space. # visual space.
#epub_theme = 'epub' # epub_theme = 'epub'
# The language of the text. It defaults to the language option # The language of the text. It defaults to the language option
# or 'en' if the language is not set. # or 'en' if the language is not set.
#epub_language = '' # epub_language = ''
# The scheme of the identifier. Typical schemes are ISBN or URL. # The scheme of the identifier. Typical schemes are ISBN or URL.
#epub_scheme = '' # epub_scheme = ''
# The unique identifier of the text. This can be a ISBN number # The unique identifier of the text. This can be a ISBN number
# or the project homepage. # or the project homepage.
#epub_identifier = '' # epub_identifier = ''
# A unique identification for the text. # A unique identification for the text.
#epub_uid = '' # epub_uid = ''
# A tuple containing the cover image and cover page html template filenames. # A tuple containing the cover image and cover page html template filenames.
#epub_cover = () # epub_cover = ()
# A sequence of (type, uri, title) tuples for the guide element of content.opf. # A sequence of (type, uri, title) tuples for the guide element of content.opf.
#epub_guide = () # epub_guide = ()
# HTML files that should be inserted before the pages created by sphinx. # HTML files that should be inserted before the pages created by sphinx.
# The format is a list of tuples containing the path and title. # The format is a list of tuples containing the path and title.
#epub_pre_files = [] # epub_pre_files = []
# HTML files that should be inserted after the pages created by sphinx. # HTML files that should be inserted after the pages created by sphinx.
# The format is a list of tuples containing the path and title. # The format is a list of tuples containing the path and title.
#epub_post_files = [] # epub_post_files = []
# A list of files that should not be packed into the epub file. # A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html'] epub_exclude_files = ['search.html']
# The depth of the table of contents in toc.ncx. # The depth of the table of contents in toc.ncx.
#epub_tocdepth = 3 # epub_tocdepth = 3
# Allow duplicate toc entries. # Allow duplicate toc entries.
#epub_tocdup = True # epub_tocdup = True
# Choose between 'default' and 'includehidden'. # Choose between 'default' and 'includehidden'.
#epub_tocscope = 'default' # epub_tocscope = 'default'
# Fix unsupported image types using the Pillow. # Fix unsupported image types using the Pillow.
#epub_fix_images = False # epub_fix_images = False
# Scale large images. # Scale large images.
#epub_max_image_width = 0 # epub_max_image_width = 0
# How to display URL addresses: 'footnote', 'no', or 'inline'. # How to display URL addresses: 'footnote', 'no', or 'inline'.
#epub_show_urls = 'inline' # epub_show_urls = 'inline'
# If false, no index is generated. # If false, no index is generated.
#epub_use_index = True # epub_use_index = True

View file

@ -6,8 +6,8 @@ from django.views.generic import TemplateView
urlpatterns = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'),
url(r'^accounts/login/$', auth_views.login, { 'template_name': 'login.html' }, name='login'), url(r'^accounts/login/$', auth_views.login, {'template_name': 'login.html'}, name='login'),
url(r'^accounts/logout/$', auth_views.logout, { 'next_page': '/' }, name='logout'), url(r'^accounts/logout/$', auth_views.logout, {'next_page': '/'}, name='logout'),
url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), url(r'^', include('oidc_provider.urls', namespace='oidc_provider')),

View file

@ -1,5 +1,6 @@
import os import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings')
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application() application = get_wsgi_application()

View file

@ -51,7 +51,9 @@ class ClientAdmin(admin.ModelAdmin):
fieldsets = [ fieldsets = [
[_(u''), { [_(u''), {
'fields': ('name', 'client_type', 'response_type','_redirect_uris', 'jwt_alg', 'require_consent', 'reuse_consent'), 'fields': (
'name', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg', 'require_consent',
'reuse_consent'),
}], }],
[_(u'Credentials'), { [_(u'Credentials'), {
'fields': ('client_id', 'client_secret'), 'fields': ('client_id', 'client_secret'),

View file

@ -9,8 +9,8 @@ STANDARD_CLAIMS = {
'name': '', 'given_name': '', 'family_name': '', 'middle_name': '', 'nickname': '', 'name': '', 'given_name': '', 'family_name': '', 'middle_name': '', 'nickname': '',
'preferred_username': '', 'profile': '', 'picture': '', 'website': '', 'gender': '', 'preferred_username': '', 'profile': '', 'picture': '', 'website': '', 'gender': '',
'birthdate': '', 'zoneinfo': '', 'locale': '', 'updated_at': '', 'email': '', 'email_verified': '', 'birthdate': '', 'zoneinfo': '', 'locale': '', 'updated_at': '', 'email': '', 'email_verified': '',
'phone_number': '', 'phone_number_verified': '', 'address': { 'formatted': '', 'phone_number': '', 'phone_number_verified': '', 'address': {
'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', }, 'formatted': '', 'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', },
} }
@ -72,7 +72,9 @@ class ScopeClaims(object):
return aux_dic return aux_dic
@classmethod @classmethod
def get_scopes_info(cls, scopes=[]): def get_scopes_info(cls, scopes=None):
if scopes is None:
scopes = []
scopes_info = [] scopes_info = []
for name in cls.__dict__: for name in cls.__dict__:
@ -99,6 +101,7 @@ class StandardScopeClaims(ScopeClaims):
_(u'Basic profile'), _(u'Basic profile'),
_(u'Access to your basic information. Includes names, gender, birthdate and other information.'), _(u'Access to your basic information. Includes names, gender, birthdate and other information.'),
) )
def scope_profile(self): def scope_profile(self):
dic = { dic = {
'name': self.userinfo.get('name'), 'name': self.userinfo.get('name'),
@ -123,6 +126,7 @@ class StandardScopeClaims(ScopeClaims):
_(u'Email'), _(u'Email'),
_(u'Access to your email address.'), _(u'Access to your email address.'),
) )
def scope_email(self): def scope_email(self):
dic = { dic = {
'email': self.userinfo.get('email') or getattr(self.user, 'email', None), 'email': self.userinfo.get('email') or getattr(self.user, 'email', None),
@ -135,6 +139,7 @@ class StandardScopeClaims(ScopeClaims):
_(u'Phone number'), _(u'Phone number'),
_(u'Access to your phone number.'), _(u'Access to your phone number.'),
) )
def scope_phone(self): def scope_phone(self):
dic = { dic = {
'phone_number': self.userinfo.get('phone_number'), 'phone_number': self.userinfo.get('phone_number'),
@ -147,6 +152,7 @@ class StandardScopeClaims(ScopeClaims):
_(u'Address information'), _(u'Address information'),
_(u'Access to your address. Includes country, locality, street and other information.'), _(u'Access to your address. Includes country, locality, street and other information.'),
) )
def scope_address(self): def scope_address(self):
dic = { dic = {
'address': { 'address': {

View file

@ -102,8 +102,8 @@ class AuthorizeEndpoint(object):
logger.debug('[Authorize] Invalid response type: %s', self.params['response_type']) logger.debug('[Authorize] Invalid response type: %s', self.params['response_type'])
raise AuthorizeError(self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) raise AuthorizeError(self.params['redirect_uri'], 'unsupported_response_type', self.grant_type)
if not self.is_authentication and \ if (not self.is_authentication and
(self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token']): (self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token'])):
logger.debug('[Authorize] Missing openid scope.') logger.debug('[Authorize] Missing openid scope.')
raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type) raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type)
@ -165,7 +165,8 @@ class AuthorizeEndpoint(object):
id_token_dic = create_id_token(**kwargs) id_token_dic = create_id_token(**kwargs)
# Check if response_type must include id_token in the response. # Check if response_type must include id_token in the response.
if self.params['response_type'] in ['id_token', 'id_token token', 'code id_token', 'code id_token token']: if self.params['response_type'] in [
'id_token', 'id_token token', 'code id_token', 'code id_token token']:
query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) query_fragment['id_token'] = encode_id_token(id_token_dic, self.client)
else: else:
id_token_dic = {} id_token_dic = {}
@ -211,7 +212,8 @@ class AuthorizeEndpoint(object):
logger.exception('[Authorize] Error when trying to create response uri: %s', error) logger.exception('[Authorize] Error when trying to create response uri: %s', error)
raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type) raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type)
uri = uri._replace(query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True)) uri = uri._replace(
query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True))
return urlunsplit(uri) return urlunsplit(uri)
@ -264,7 +266,8 @@ class AuthorizeEndpoint(object):
""" """
scopes = StandardScopeClaims.get_scopes_info(self.params['scope']) scopes = StandardScopeClaims.get_scopes_info(self.params['scope'])
if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'):
scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(
self.params['scope'])
for index_extra, scope_extra in enumerate(scopes_extra): for index_extra, scope_extra in enumerate(scopes_extra):
for index, scope in enumerate(scopes[:]): for index, scope in enumerate(scopes[:]):
if scope_extra['scope'] == scope['scope']: if scope_extra['scope'] == scope['scope']:

View file

@ -4,11 +4,6 @@ import logging
import re import re
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
try:
from urllib.parse import unquote
except ImportError:
from urllib import unquote
from django.http import JsonResponse from django.http import JsonResponse
from oidc_provider.lib.errors import ( from oidc_provider.lib.errors import (

View file

@ -31,6 +31,7 @@ class UserAuthError(Exception):
'error_description': self.description, 'error_description': self.description,
} }
class AuthorizeError(Exception): class AuthorizeError(Exception):
_errors = { _errors = {

View file

@ -5,11 +5,6 @@ from django.http import HttpResponse
from oidc_provider import settings from oidc_provider import settings
try:
from urlparse import urlsplit, urlunsplit
except ImportError:
from urllib.parse import urlsplit, urlunsplit
def redirect(uri): def redirect(uri):
""" """
@ -75,7 +70,8 @@ def default_after_userlogin_hook(request, user, client):
return None return None
def default_after_end_session_hook(request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): def default_after_end_session_hook(
request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None):
""" """
Default function for setting OIDC_AFTER_END_SESSION_HOOK. Default function for setting OIDC_AFTER_END_SESSION_HOOK.
@ -91,7 +87,8 @@ def default_after_end_session_hook(request, id_token=None, post_logout_redirect_
:param state: state param from url query params :param state: state param from url query params
:type state: str :type state: str
:param client: If id_token has `aud` param and associated Client exists, this is an instance of it - do NOT trust this param :param client: If id_token has `aud` param and associated Client exists,
this is an instance of it - do NOT trust this param
:type client: oidc_provider.models.Client :type client: oidc_provider.models.Client
:param next_page: calculated next_page redirection target :param next_page: calculated next_page redirection target

View file

@ -28,12 +28,15 @@ def extract_access_token(request):
return access_token return access_token
def protected_resource_view(scopes=[]): def protected_resource_view(scopes=None):
""" """
View decorator. The client accesses protected resources by presenting the View decorator. The client accesses protected resources by presenting the
access token to the resource server. access token to the resource server.
https://tools.ietf.org/html/rfc6749#section-7 https://tools.ietf.org/html/rfc6749#section-7
""" """
if scopes is None:
scopes = []
def wrapper(view): def wrapper(view):
def view_wrapper(request, *args, **kwargs): def view_wrapper(request, *args, **kwargs):
access_token = extract_access_token(request) access_token = extract_access_token(request)
@ -52,9 +55,10 @@ def protected_resource_view(scopes=[]):
if not set(scopes).issubset(set(kwargs['token'].scope)): if not set(scopes).issubset(set(kwargs['token'].scope)):
logger.debug('[UserInfo] Missing openid scope.') logger.debug('[UserInfo] Missing openid scope.')
raise BearerTokenError('insufficient_scope') raise BearerTokenError('insufficient_scope')
except (BearerTokenError) as error: except BearerTokenError as error:
response = HttpResponse(status=error.status) response = HttpResponse(status=error.status)
response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(error.code, error.description) response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(
error.code, error.description)
return response return response
return view(request, *args, **kwargs) return view(request, *args, **kwargs)

View file

@ -18,12 +18,14 @@ from oidc_provider.models import (
from oidc_provider import settings from oidc_provider import settings
def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=None):
""" """
Creates the id_token dictionary. Creates the id_token dictionary.
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken
Return a dic. Return a dic.
""" """
if scope is None:
scope = []
sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR', import_str=True)(user=user) sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR', import_str=True)(user=user)
expires_in = settings.get('OIDC_IDTOKEN_EXPIRE') expires_in = settings.get('OIDC_IDTOKEN_EXPIRE')
@ -63,6 +65,7 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]):
return dic return dic
def encode_id_token(payload, client): def encode_id_token(payload, client):
""" """
Represent the ID Token as a JSON Web Token (JWT). Represent the ID Token as a JSON Web Token (JWT).
@ -72,6 +75,7 @@ def encode_id_token(payload, client):
_jws = JWS(payload, alg=client.jwt_alg) _jws = JWS(payload, alg=client.jwt_alg)
return _jws.sign_compact(keys) return _jws.sign_compact(keys)
def decode_id_token(token, client): def decode_id_token(token, client):
""" """
Represent the ID Token as a JSON Web Token (JWT). Represent the ID Token as a JSON Web Token (JWT).
@ -80,6 +84,7 @@ def decode_id_token(token, client):
keys = get_client_alg_keys(client) keys = get_client_alg_keys(client)
return JWS().verify_compact(token, keys=keys) return JWS().verify_compact(token, keys=keys)
def client_id_from_id_token(id_token): def client_id_from_id_token(id_token):
""" """
Extracts the client id from a JSON Web Token (JWT). Extracts the client id from a JSON Web Token (JWT).
@ -88,6 +93,7 @@ def client_id_from_id_token(id_token):
payload = JWT().unpack(id_token).payload() payload = JWT().unpack(id_token).payload()
return payload.get('aud', None) return payload.get('aud', None)
def create_token(user, client, scope, id_token_dic=None): def create_token(user, client, scope, id_token_dic=None):
""" """
Create and populate a Token object. Create and populate a Token object.
@ -108,6 +114,7 @@ def create_token(user, client, scope, id_token_dic=None):
return token return token
def create_code(user, client, scope, nonce, is_authentication, def create_code(user, client, scope, nonce, is_authentication,
code_challenge=None, code_challenge_method=None): code_challenge=None, code_challenge_method=None):
""" """
@ -132,6 +139,7 @@ def create_code(user, client, scope, nonce, is_authentication,
return code return code
def get_client_alg_keys(client): def get_client_alg_keys(client):
""" """
Takes a client and returns the set of keys associated with it. Takes a client and returns the set of keys associated with it.

View file

@ -1,9 +1,6 @@
import os
from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey import RSA
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from oidc_provider import settings
from oidc_provider.models import RSAKey from oidc_provider.models import RSAKey

View file

@ -20,7 +20,9 @@ class Migration(migrations.Migration):
('name', models.CharField(default=b'', max_length=100)), ('name', models.CharField(default=b'', max_length=100)),
('client_id', models.CharField(unique=True, max_length=255)), ('client_id', models.CharField(unique=True, max_length=255)),
('client_secret', models.CharField(unique=True, max_length=255)), ('client_secret', models.CharField(unique=True, max_length=255)),
('response_type', models.CharField(max_length=30, choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')])), ('response_type', models.CharField(max_length=30, choices=[
(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'),
(b'id_token token', b'id_token token (Implicit Flow)')])),
('_redirect_uris', models.TextField(default=b'')), ('_redirect_uris', models.TextField(default=b'')),
], ],
options={ options={

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):

View file

@ -29,7 +29,8 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='date_created', name='date_created',
field=models.DateField(auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)), field=models.DateField(
auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)),
preserve_default=False, preserve_default=False,
), ),
migrations.AlterField( migrations.AlterField(

View file

@ -15,6 +15,11 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='client_type', name='client_type',
field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30), field=models.CharField(
choices=[(b'confidential', b'Confidential'), (b'public', b'Public')],
default=b'confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> clients are incapable.',
max_length=30),
), ),
] ]

View file

@ -15,6 +15,10 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='jwt_alg', name='jwt_alg',
field=models.CharField(choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], default=b'RS256', max_length=10, verbose_name='JWT Algorithm'), field=models.CharField(
choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')],
default=b'RS256',
max_length=10,
verbose_name='JWT Algorithm'),
), ),
] ]

View file

@ -25,12 +25,21 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='client_type', name='client_type',
field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30), field=models.CharField(
choices=[('confidential', 'Confidential'), ('public', 'Public')],
default='confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their'
' credentials. <b>Public</b> clients are incapable.',
max_length=30),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='jwt_alg', name='jwt_alg',
field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', max_length=10, verbose_name='JWT Algorithm'), field=models.CharField(
choices=[('HS256', 'HS256'), ('RS256', 'RS256')],
default='RS256',
max_length=10,
verbose_name='JWT Algorithm'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
@ -40,7 +49,11 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='response_type', name='response_type',
field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30), field=models.CharField(
choices=[
('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'),
('id_token token', 'id_token token (Implicit Flow)')],
max_length=30),
), ),
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',

View file

@ -19,13 +19,15 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='userconsent', model_name='userconsent',
name='date_given', name='date_given',
field=models.DateTimeField(default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'), field=models.DateTimeField(
default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'),
preserve_default=False, preserve_default=False,
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='_redirect_uris', name='_redirect_uris',
field=models.TextField(default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), field=models.TextField(
default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
@ -40,7 +42,13 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='client_type', name='client_type',
field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30, verbose_name='Client Type'), field=models.CharField(
choices=[(b'confidential', b'Confidential'), (b'public', b'Public')],
default=b'confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> clients are incapable.',
max_length=30,
verbose_name='Client Type'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
@ -55,7 +63,12 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='response_type', name='response_type',
field=models.CharField(choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), field=models.CharField(
choices=[
(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'),
(b'id_token token', b'id_token token (Implicit Flow)')],
max_length=30,
verbose_name='Response Type'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',
@ -65,7 +78,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',
name='client', name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',
@ -100,7 +114,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',
name='user', name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='rsakey', model_name='rsakey',
@ -125,7 +140,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='token', model_name='token',
name='client', name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='token', model_name='token',
@ -140,7 +156,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='token', model_name='token',
name='user', name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userconsent', model_name='userconsent',
@ -150,7 +167,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='userconsent', model_name='userconsent',
name='client', name='client',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userconsent', model_name='userconsent',
@ -160,6 +178,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='userconsent', model_name='userconsent',
name='user', name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'),
), ),
] ]

View file

@ -25,7 +25,13 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='client_type', name='client_type',
field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30, verbose_name='Client Type'), field=models.CharField(
choices=[('confidential', 'Confidential'), ('public', 'Public')],
default='confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> clients are incapable.',
max_length=30,
verbose_name='Client Type'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
@ -35,7 +41,12 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='response_type', name='response_type',
field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), field=models.CharField(
choices=[
('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'),
('id_token token', 'id_token token (Implicit Flow)')],
max_length=30,
verbose_name='Response Type'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='code', model_name='code',

View file

@ -20,12 +20,18 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='logo', name='logo',
field=models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), field=models.FileField(
blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'),
), ),
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='terms_url', name='terms_url',
field=models.CharField(blank=True, default='', help_text='External reference to the privacy policy of the client.', max_length=255, verbose_name='Terms URL'), field=models.CharField(
blank=True,
default='',
help_text='External reference to the privacy policy of the client.',
max_length=255,
verbose_name='Terms URL'),
), ),
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
@ -35,11 +41,23 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='jwt_alg', name='jwt_alg',
field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', help_text='Algorithm used to encode ID Tokens.', max_length=10, verbose_name='JWT Algorithm'), field=models.CharField(
choices=[('HS256', 'HS256'), ('RS256', 'RS256')],
default='RS256',
help_text='Algorithm used to encode ID Tokens.',
max_length=10,
verbose_name='JWT Algorithm'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='client', model_name='client',
name='response_type', name='response_type',
field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, verbose_name='Response Type'), field=models.CharField(
choices=[
('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'),
('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'),
('code id_token', 'code id_token (Hybrid Flow)'),
('code id_token token', 'code id_token token (Hybrid Flow)')],
max_length=30,
verbose_name='Response Type'),
), ),
] ]

View file

@ -15,6 +15,10 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='_post_logout_redirect_uris', name='_post_logout_redirect_uris',
field=models.TextField(blank=True, default='', help_text='Enter each URI on a new line.', verbose_name='Post Logout Redirect URIs'), field=models.TextField(
blank=True,
default='',
help_text='Enter each URI on a new line.',
verbose_name='Post Logout Redirect URIs'),
), ),
] ]

View file

@ -15,11 +15,18 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='require_consent', name='require_consent',
field=models.BooleanField(default=True, help_text='If disabled, the Server will NEVER ask the user for consent.', verbose_name='Require Consent?'), field=models.BooleanField(
default=True,
help_text='If disabled, the Server will NEVER ask the user for consent.',
verbose_name='Require Consent?'),
), ),
migrations.AddField( migrations.AddField(
model_name='client', model_name='client',
name='reuse_consent', name='reuse_consent',
field=models.BooleanField(default=True, help_text="If enabled, the Server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), field=models.BooleanField(
default=True,
help_text="If enabled, the Server will save the user consent given to a specific client,"
" so that user won't be prompted for the same authorization multiple times.",
verbose_name='Reuse Consent?'),
), ),
] ]

View file

@ -33,36 +33,66 @@ JWT_ALGS = [
class Client(models.Model): class Client(models.Model):
name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) name = models.CharField(max_length=100, default='', verbose_name=_(u'Name'))
client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', verbose_name=_(u'Client Type'), help_text=_(u'<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.')) client_type = models.CharField(
max_length=30,
choices=CLIENT_TYPE_CHOICES,
default='confidential',
verbose_name=_(u'Client Type'),
help_text=_(u'<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. '
u'<b>Public</b> clients are incapable.'))
client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID'))
client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET'))
response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type'))
jwt_alg = models.CharField(max_length=10, choices=JWT_ALGS, default='RS256', verbose_name=_(u'JWT Algorithm'), help_text=_(u'Algorithm used to encode ID Tokens.')) jwt_alg = models.CharField(
max_length=10,
choices=JWT_ALGS,
default='RS256',
verbose_name=_(u'JWT Algorithm'),
help_text=_(u'Algorithm used to encode ID Tokens.'))
date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created'))
website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL'))
terms_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Terms URL'), help_text=_(u'External reference to the privacy policy of the client.')) terms_url = models.CharField(
max_length=255,
blank=True,
default='',
verbose_name=_(u'Terms URL'),
help_text=_(u'External reference to the privacy policy of the client.'))
contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email'))
logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image'))
reuse_consent = models.BooleanField(default=True, verbose_name=_('Reuse Consent?'), help_text=_('If enabled, the Server will save the user consent given to a specific client, so that user won\'t be prompted for the same authorization multiple times.')) reuse_consent = models.BooleanField(
require_consent = models.BooleanField(default=True, verbose_name=_('Require Consent?'), help_text=_('If disabled, the Server will NEVER ask the user for consent.')) default=True,
verbose_name=_('Reuse Consent?'),
help_text=_('If enabled, the Server will save the user consent given to a specific client, so that'
' user won\'t be prompted for the same authorization multiple times.'))
require_consent = models.BooleanField(
default=True,
verbose_name=_('Require Consent?'),
help_text=_('If disabled, the Server will NEVER ask the user for consent.'))
_redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) _redirect_uris = models.TextField(
def redirect_uris(): default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.'))
def fget(self):
return self._redirect_uris.splitlines()
def fset(self, value):
self._redirect_uris = '\n'.join(value)
return locals()
redirect_uris = property(**redirect_uris())
_post_logout_redirect_uris = models.TextField(blank=True, default='', verbose_name=_(u'Post Logout Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) @property
def post_logout_redirect_uris(): def redirect_uris(self):
def fget(self): return self._redirect_uris.splitlines()
return self._post_logout_redirect_uris.splitlines()
def fset(self, value): @redirect_uris.setter
self._post_logout_redirect_uris = '\n'.join(value) def redirect_uris(self, value):
return locals() self._redirect_uris = '\n'.join(value)
post_logout_redirect_uris = property(**post_logout_redirect_uris())
_post_logout_redirect_uris = models.TextField(
blank=True,
default='',
verbose_name=_(u'Post Logout Redirect URIs'),
help_text=_(u'Enter each URI on a new line.'))
@property
def post_logout_redirect_uris(self):
return self._post_logout_redirect_uris.splitlines()
@post_logout_redirect_uris.setter
def post_logout_redirect_uris(self, value):
self._post_logout_redirect_uris = '\n'.join(value)
class Meta: class Meta:
verbose_name = _(u'Client') verbose_name = _(u'Client')
@ -74,8 +104,6 @@ class Client(models.Model):
def __unicode__(self): def __unicode__(self):
return self.__str__() return self.__str__()
@property @property
def default_redirect_uri(self): def default_redirect_uri(self):
return self.redirect_uris[0] if self.redirect_uris else '' return self.redirect_uris[0] if self.redirect_uris else ''
@ -88,15 +116,13 @@ class BaseCodeTokenModel(models.Model):
expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date'))
_scope = models.TextField(default='', verbose_name=_(u'Scopes')) _scope = models.TextField(default='', verbose_name=_(u'Scopes'))
def scope(): @property
def fget(self): def scope(self):
return self._scope.split() return self._scope.split()
def fset(self, value): @scope.setter
self._scope = ' '.join(value) def scope(self, value):
self._scope = ' '.join(value)
return locals()
scope = property(**scope())
def has_expired(self): def has_expired(self):
return timezone.now() >= self.expires_at return timezone.now() >= self.expires_at
@ -130,16 +156,13 @@ class Token(BaseCodeTokenModel):
refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token'))
_id_token = models.TextField(verbose_name=_(u'ID Token')) _id_token = models.TextField(verbose_name=_(u'ID Token'))
def id_token(): @property
def id_token(self):
return json.loads(self._id_token)
def fget(self): @id_token.setter
return json.loads(self._id_token) def id_token(self, value):
self._id_token = json.dumps(value)
def fset(self, value):
self._id_token = json.dumps(value)
return locals()
id_token = property(**id_token())
class Meta: class Meta:
verbose_name = _(u'Token') verbose_name = _(u'Token')

View file

@ -145,6 +145,7 @@ class DefaultSettings(object):
'error': 'oidc_provider/error.html' 'error': 'oidc_provider/error.html'
} }
default_settings = DefaultSettings() default_settings = DefaultSettings()

View file

@ -34,7 +34,9 @@ from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint
class AuthorizeEndpointMixin(object): class AuthorizeEndpointMixin(object):
def _auth_request(self, method, data={}, is_user_authenticated=False): def _auth_request(self, method, data=None, is_user_authenticated=False):
if data is None:
data = {}
url = reverse('oidc_provider:authorize') url = reverse('oidc_provider:authorize')
if method.lower() == 'get': if method.lower() == 'get':
@ -67,7 +69,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.client = create_fake_client(response_type='code') self.client = create_fake_client(response_type='code')
self.client_with_no_consent = create_fake_client(response_type='code', require_consent=False) self.client_with_no_consent = create_fake_client(response_type='code', require_consent=False)
self.client_public = create_fake_client(response_type='code', is_public=True) self.client_public = create_fake_client(response_type='code', is_public=True)
self.client_public_with_no_consent = create_fake_client(response_type='code', is_public=True, require_consent=False) self.client_public_with_no_consent = create_fake_client(
response_type='code', is_public=True, require_consent=False)
self.state = uuid.uuid4().hex self.state = uuid.uuid4().hex
self.nonce = uuid.uuid4().hex self.nonce = uuid.uuid4().hex
@ -163,8 +166,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
for key, value in iter(to_check.items()): for key, value in iter(to_check.items()):
is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') is_input_ok = input_html.format(key, value) in response.content.decode('utf-8')
self.assertEqual(is_input_ok, True, self.assertEqual(is_input_ok, True, msg='Hidden input for "' + key + '" fails.')
msg='Hidden input for "' + key + '" fails.')
def test_user_consent_response(self): def test_user_consent_response(self):
""" """
@ -204,8 +206,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
is_code_ok = is_code_valid(url=response['Location'], is_code_ok = is_code_valid(url=response['Location'],
user=self.user, user=self.user,
client=self.client) client=self.client)
self.assertEqual(is_code_ok, True, self.assertEqual(is_code_ok, True, msg='Code returned is invalid.')
msg='Code returned is invalid.')
# Check if the state is returned. # Check if the state is returned.
state = (response['Location'].split('state='))[1].split('&')[0] state = (response['Location'].split('state='))[1].split('&')[0]
@ -276,9 +277,10 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
client=self.client) client=self.client)
self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') self.assertTrue(is_code_ok, msg='Code returned is invalid or missing')
self.assertEquals(set(params.keys()), set(['state', 'code']), msg='More than state or code appended as query params') self.assertEquals(set(params.keys()), {'state', 'code'}, msg='More than state or code appended as query params')
self.assertTrue(response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned') self.assertTrue(
response['Location'].startswith(self.client.default_redirect_uri), msg='Different redirect_uri returned')
def test_unknown_redirect_uris_are_rejected(self): def test_unknown_redirect_uris_are_rejected(self):
""" """
@ -372,7 +374,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.assertNotIn( self.assertNotIn(
quote('prompt=login'), quote('prompt=login'),
response['Location'], response['Location'],
"Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." "Found prompt=login, this leads to infinite login loop. See "
"https://github.com/juanifioren/django-oidc-provider/issues/197."
) )
response = self._auth_request('get', data, is_user_authenticated=True) response = self._auth_request('get', data, is_user_authenticated=True)
@ -381,7 +384,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.assertNotIn( self.assertNotIn(
quote('prompt=login'), quote('prompt=login'),
response['Location'], response['Location'],
"Found prompt=login, this leads to infinite login loop. See https://github.com/juanifioren/django-oidc-provider/issues/197." "Found prompt=login, this leads to infinite login loop. See "
"https://github.com/juanifioren/django-oidc-provider/issues/197."
) )
def test_prompt_login_none_parameter(self): def test_prompt_login_none_parameter(self):
@ -447,7 +451,6 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin):
self.assertIn('consent_required', response['Location']) self.assertIn('consent_required', response['Location'])
class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
""" """
Test cases for Authorization Endpoint using Implicit Flow. Test cases for Authorization Endpoint using Implicit Flow.

View file

@ -50,5 +50,6 @@ class EndSessionTestCase(TestCase):
def test_call_post_end_session_hook(self, hook_function): def test_call_post_end_session_hook(self, hook_function):
self.client.get(self.url) self.client.get(self.url)
self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called')
self.assertTrue(hook_function.call_count == 1, 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) self.assertTrue(
hook_function.call_count == 1,
'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count))

View file

@ -10,6 +10,7 @@ class StubbedViews:
urlpatterns = [url('^test/', SampleView.as_view())] urlpatterns = [url('^test/', SampleView.as_view())]
MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware',
'oidc_provider.middleware.SessionManagementMiddleware') 'oidc_provider.middleware.SessionManagementMiddleware')

View file

@ -18,7 +18,7 @@ from django.test import TestCase
from jwkest.jwk import KEYS from jwkest.jwk import KEYS
from jwkest.jws import JWS from jwkest.jws import JWS
from jwkest.jwt import JWT from jwkest.jwt import JWT
from mock import patch, Mock from mock import patch
from oidc_provider.lib.utils.token import create_code from oidc_provider.lib.utils.token import create_code
from oidc_provider.models import Token from oidc_provider.models import Token
@ -101,7 +101,8 @@ class TokenTestCase(TestCase):
""" """
url = reverse('oidc_provider:token') url = reverse('oidc_provider:token')
request = self.factory.post(url, request = self.factory.post(
url,
data=urlencode(post_data), data=urlencode(post_data),
content_type='application/x-www-form-urlencoded', content_type='application/x-www-form-urlencoded',
**extras) **extras)
@ -371,7 +372,7 @@ class TokenTestCase(TestCase):
response_dic2 = json.loads(response.content.decode('utf-8')) response_dic2 = json.loads(response.content.decode('utf-8'))
if scope and set(scope) - set(code.scope): # too broad scope if scope and set(scope) - set(code.scope): # too broad scope
self.assertEqual(response.status_code, 400) # Bad Request self.assertEqual(response.status_code, 400) # Bad Request
self.assertIn('error', response_dic2) self.assertIn('error', response_dic2)
self.assertEqual(response_dic2['error'], 'invalid_scope') self.assertEqual(response_dic2['error'], 'invalid_scope')
@ -427,7 +428,6 @@ class TokenTestCase(TestCase):
See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and
http://openid.net/specs/openid-connect-core-1_0.html#HybridTokenRequest. http://openid.net/specs/openid-connect-core-1_0.html#HybridTokenRequest.
""" """
SIGKEYS = self._get_keys()
code = self._create_code() code = self._create_code()
post_data = self._auth_code_post_data(code=code.code) post_data = self._auth_code_post_data(code=code.code)
@ -465,15 +465,13 @@ class TokenTestCase(TestCase):
for request in requests: for request in requests:
response = TokenView.as_view()(request) response = TokenView.as_view()(request)
self.assertEqual(response.status_code == 405, True, self.assertEqual(response.status_code, 405, msg=request.method + ' request does not return a 405 status.')
msg=request.method + ' request does not return a 405 status.')
request = self.factory.post(url) request = self.factory.post(url)
response = TokenView.as_view()(request) response = TokenView.as_view()(request)
self.assertEqual(response.status_code == 400, True, self.assertEqual(response.status_code, 400, msg=request.method + ' request does not return a 400 status.')
msg=request.method + ' request does not return a 400 status.')
def test_client_authentication(self): def test_client_authentication(self):
""" """
@ -490,9 +488,10 @@ class TokenTestCase(TestCase):
response = self._post_request(post_data) response = self._post_request(post_data)
self.assertEqual('invalid_client' in response.content.decode('utf-8'), self.assertNotIn(
False, 'invalid_client',
msg='Client authentication fails using request-body credentials.') response.content.decode('utf-8'),
msg='Client authentication fails using request-body credentials.')
# Now, test with an invalid client_id. # Now, test with an invalid client_id.
invalid_data = post_data.copy() invalid_data = post_data.copy()
@ -504,9 +503,10 @@ class TokenTestCase(TestCase):
response = self._post_request(invalid_data) response = self._post_request(invalid_data)
self.assertEqual('invalid_client' in response.content.decode('utf-8'), self.assertIn(
True, 'invalid_client',
msg='Client authentication success with an invalid "client_id".') response.content.decode('utf-8'),
msg='Client authentication success with an invalid "client_id".')
# Now, test using HTTP Basic Authentication method. # Now, test using HTTP Basic Authentication method.
basicauth_data = post_data.copy() basicauth_data = post_data.copy()
@ -521,9 +521,10 @@ class TokenTestCase(TestCase):
response = self._post_request(basicauth_data, self._password_grant_auth_header()) response = self._post_request(basicauth_data, self._password_grant_auth_header())
response.content.decode('utf-8') response.content.decode('utf-8')
self.assertEqual('invalid_client' in response.content.decode('utf-8'), self.assertNotIn(
False, 'invalid_client',
msg='Client authentication fails using HTTP Basic Auth.') response.content.decode('utf-8'),
msg='Client authentication fails using HTTP Basic Auth.')
def test_access_token_contains_nonce(self): def test_access_token_contains_nonce(self):
""" """
@ -588,7 +589,7 @@ class TokenTestCase(TestCase):
response = self._post_request(post_data) response = self._post_request(post_data)
response_dic = json.loads(response.content.decode('utf-8')) response_dic = json.loads(response.content.decode('utf-8'))
id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS)
@override_settings(OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') @override_settings(OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator')
def test_custom_sub_generator(self): def test_custom_sub_generator(self):
@ -732,4 +733,4 @@ class TokenTestCase(TestCase):
response = self._post_request(post_data) response = self._post_request(post_data)
response_dic = json.loads(response.content.decode('utf-8')) json.loads(response.content.decode('utf-8'))

View file

@ -30,10 +30,12 @@ class UserInfoTestCase(TestCase):
self.user = create_fake_user() self.user = create_fake_user()
self.client = create_fake_client(response_type='code') self.client = create_fake_client(response_type='code')
def _create_token(self, extra_scope=[]): def _create_token(self, extra_scope=None):
""" """
Generate a valid token. Generate a valid token.
""" """
if extra_scope is None:
extra_scope = []
scope = ['openid', 'email'] + extra_scope scope = ['openid', 'email'] + extra_scope
id_token_dic = create_id_token( id_token_dic = create_id_token(
@ -60,9 +62,7 @@ class UserInfoTestCase(TestCase):
""" """
url = reverse('oidc_provider:userinfo') url = reverse('oidc_provider:userinfo')
request = self.factory.post(url, request = self.factory.post(url, data={}, content_type='multipart/form-data')
data={},
content_type='multipart/form-data')
request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token
@ -136,17 +136,13 @@ class UserInfoTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(bool(response.content), True) self.assertEqual(bool(response.content), True)
self.assertEqual('given_name' in response_dic, True, self.assertIn('given_name', response_dic, msg='"given_name" claim should be in response.')
msg='"given_name" claim should be in response.') self.assertNotIn('profile', response_dic, msg='"profile" claim should not be in response.')
self.assertEqual('profile' in response_dic, False,
msg='"profile" claim should not be in response.')
# Now adding `address` scope. # Now adding `address` scope.
token = self._create_token(extra_scope=['profile', 'address']) token = self._create_token(extra_scope=['profile', 'address'])
response = self._post_request(token.access_token) response = self._post_request(token.access_token)
response_dic = json.loads(response.content.decode('utf-8')) response_dic = json.loads(response.content.decode('utf-8'))
self.assertEqual('address' in response_dic, True, self.assertIn('address', response_dic, msg='"address" claim should be in response.')
msg='"address" claim should be in response.') self.assertIn('country', response_dic['address'], msg='"country" claim should be in response.')
self.assertEqual('country' in response_dic['address'], True,
msg='"country" claim should be in response.')

View file

@ -78,7 +78,8 @@ class AuthorizeView(View):
if 'select_account' in authorize.params['prompt']: if 'select_account' in authorize.params['prompt']:
# TODO: see how we can support multiple accounts for the end-user. # TODO: see how we can support multiple accounts for the end-user.
if 'none' in authorize.params['prompt']: if 'none' in authorize.params['prompt']:
raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) raise AuthorizeError(
authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type)
else: else:
django_user_logout(request) django_user_logout(request)
return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL'))
@ -86,7 +87,7 @@ class AuthorizeView(View):
if {'none', 'consent'}.issubset(authorize.params['prompt']): if {'none', 'consent'}.issubset(authorize.params['prompt']):
raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) raise AuthorizeError(authorize.params['redirect_uri'], 'consent_required', authorize.grant_type)
implicit_flow_resp_types = set(['id_token', 'id_token token']) implicit_flow_resp_types = {'id_token', 'id_token token'}
allow_skipping_consent = ( allow_skipping_consent = (
authorize.client.client_type != 'public' or authorize.client.client_type != 'public' or
authorize.client.response_type in implicit_flow_resp_types) authorize.client.response_type in implicit_flow_resp_types)
@ -156,13 +157,15 @@ class AuthorizeView(View):
authorize.validate_params() authorize.validate_params()
if not request.POST.get('allow'): if not request.POST.get('allow'):
signals.user_decline_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) signals.user_decline_consent.send(
self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope'])
raise AuthorizeError(authorize.params['redirect_uri'], raise AuthorizeError(authorize.params['redirect_uri'],
'access_denied', 'access_denied',
authorize.grant_type) authorize.grant_type)
signals.user_accept_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) signals.user_accept_consent.send(
self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope'])
# Save the user consent given to the client. # Save the user consent given to the client.
authorize.set_client_user_consent() authorize.set_client_user_consent()
@ -171,7 +174,7 @@ class AuthorizeView(View):
return redirect(uri) return redirect(uri)
except (AuthorizeError) as error: except AuthorizeError as error:
uri = error.create_uri( uri = error.create_uri(
authorize.params['redirect_uri'], authorize.params['redirect_uri'],
authorize.params['state']) authorize.params['state'])

View file

@ -9,24 +9,24 @@ from django.conf import settings
DEFAULT_SETTINGS = dict( DEFAULT_SETTINGS = dict(
DEBUG = False, DEBUG=False,
DATABASES = { DATABASES={
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:', 'NAME': ':memory:',
} }
}, },
SITE_ID = 1, SITE_ID=1,
MIDDLEWARE_CLASSES = [ MIDDLEWARE_CLASSES=[
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
], ],
TEMPLATES = [ TEMPLATES=[
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [],
@ -42,7 +42,7 @@ DEFAULT_SETTINGS = dict(
}, },
], ],
LOGGING = { LOGGING={
'version': 1, 'version': 1,
'disable_existing_loggers': False, 'disable_existing_loggers': False,
'handlers': { 'handlers': {
@ -58,7 +58,7 @@ DEFAULT_SETTINGS = dict(
}, },
}, },
INSTALLED_APPS = [ INSTALLED_APPS=[
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
@ -68,20 +68,20 @@ DEFAULT_SETTINGS = dict(
'oidc_provider', 'oidc_provider',
], ],
SECRET_KEY = 'this-should-be-top-secret', SECRET_KEY='this-should-be-top-secret',
ROOT_URLCONF = 'oidc_provider.tests.app.urls', ROOT_URLCONF='oidc_provider.tests.app.urls',
TEMPLATE_DIRS = [ TEMPLATE_DIRS=[
'oidc_provider/tests/templates', 'oidc_provider/tests/templates',
], ],
USE_TZ = True, USE_TZ=True,
# OIDC Provider settings. # OIDC Provider settings.
SITE_URL = 'http://localhost:8000', SITE_URL='http://localhost:8000',
OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo', OIDC_USERINFO='oidc_provider.tests.app.utils.userinfo',
) )

View file

@ -6,6 +6,7 @@ envlist=
py34-django{17,18,19,110,111}, py34-django{17,18,19,110,111},
py35-django{18,19,110,111}, py35-django{18,19,110,111},
py36-django{18,19,110,111}, py36-django{18,19,110,111},
flake8
[testenv] [testenv]
@ -30,3 +31,9 @@ commands=
commands= commands=
coverage report -m coverage report -m
[testenv:flake8]
basepython=python
deps=flake8
commands =
flake8 --max-line-length=120