diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..dc929cc --- /dev/null +++ b/.drone.yml @@ -0,0 +1,46 @@ +pipeline: + restore-cache: + image: drillster/drone-volume-cache + restore: true + mount: + - cache + - .tox + volumes: + - /tmp/drone-cache:/cache + + build: + image: python:3.5 + commands: + - mkdir -p cache/pip + - pip -q install --upgrade --cache-dir cache/pip tox flake8 + - tox -e py35-django110 + # - flake8 --config=.flake8rc + # - "isort -df -c -rc" + + dist: + image: python:3.5 + commands: + - '[ "${DRONE_TAG##v}" = "$$(python setup.py -V)" ]' + - python setup.py sdist bdist_wheel + when: + event: tag + tag: v* + + pypi: + image: thomasf/twine + commands: + - twine upload dist/* + secrets: [ twine_username, twine_password ] + when: + event: tag + tag: v* + + + rebuild-cache: + image: drillster/drone-volume-cache + rebuild: true + mount: + - cache + - .tox + volumes: + - /tmp/drone-cache:/cache diff --git a/.gitignore b/.gitignore index 684c515..2978e75 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ var/ *.egg-info/ .installed.cfg *.egg +.venv # Installer logs pip-log.txt diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..3c0fc82 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +skip=.tox \ No newline at end of file diff --git a/README.rst b/README.rst index 3e6d669..2356be1 100644 --- a/README.rst +++ b/README.rst @@ -7,18 +7,21 @@ Behind the scenes, it uses Roland Hedberg's great pyoidc library. Modified by JHUAPL BOSS to support Python3 +Modified by Thomas Frössman with fixes and additional modifications. + Quickstart ---------- Install djangooidc:: # Latest code - unstable! - pip install git+https://github.com/jhuapl-boss/django-oidc.git - + pip install git+https://github.com/{desiredforkname}/django-oidc.git + +(replace {desiredforkname} by the github username; find the fork which suit your needs, or just copy the name from your browser location field). Then to use it in a Django project, add this to your urls.py:: - url(r'openid/', include('djangooidc.urls')), + url(r'^openid/', include('djangooidc.urls')), Then add the following items to your settings.py: @@ -66,6 +69,26 @@ For example, an Azure AD OP would be:: You may now test the authentication by going to (on the development server) http://localhost:8000/openid/login or to any of your views that requires authentication. +Using a private key jwt for client authentication +------------------------------------------------- +If you are using private keys for client authentication with the OP, you can specify it like:: + + OIDC_PROVIDERS = { + "mitreid": { + "srv_discovery_url": "https://mitreid.org/", + "behaviour": OIDC_DEFAULT_BEHAVIOUR, + "client_registration": { + "client_id": "your_client_id", + "redirect_uris": ["http://localhost:8000/openid/callback/login/"], + 'token_endpoint_auth_method': ['private_key_jwt'], + "enc_kid": "rsa_test", + "keyset_jwk_file": "file://keys/keyset.jwk" + } + } + } + +In this case keys/keyset.jwk is the full keyset (public and private keys) used when registering the client with the OP +manually. (I.E. you've provided the OP with the public key.) Features -------- diff --git a/django_rp/settings.py b/django_rp/settings.py index 93702b3..be3a6f8 100644 --- a/django_rp/settings.py +++ b/django_rp/settings.py @@ -1,5 +1,6 @@ # Django settings for access_web project. import os +import django DEBUG = True TEMPLATE_DEBUG = DEBUG @@ -36,7 +37,7 @@ # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-US' +LANGUAGE_CODE = 'en-us' SITE_ID = 1 @@ -95,13 +96,25 @@ 'django.template.loaders.app_directories.Loader', ) -MIDDLEWARE_CLASSES = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', -) +if django.VERSION >= (2, 1): + MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + ] +else: + MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ) + SESSION_ENGINE = 'django.contrib.sessions.backends.db' @@ -121,9 +134,28 @@ ROOT_URLCONF = 'django_rp.urls' -TEMPLATE_DIRS = ( - # os.path.join(BASE_DIR, "django_rp/templates"), -) +if django.VERSION >= (2, 1): + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'djangooidc/templates'), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, + ] +else: + TEMPLATE_DIRS = ( + # os.path.join(BASE_DIR, "django_rp/templates"), + ) INSTALLED_APPS = ( 'django.contrib.auth', @@ -218,16 +250,16 @@ # The keys in this dictionary are the OPs (OpenID Providers) short user friendly name not the issuer (iss) name. OIDC_PROVIDERS = { # Test OP - webfinger supported on non-standard URL, no client self registration. - "Azure Active Directory": { - "srv_discovery_url": "https://sts.windows.net/9019caa7-f3ba-4261-8b4f-9162bdbe8cd1/", - "behaviour": OIDC_DEFAULT_BEHAVIOUR, - "client_registration": { - "client_id": "0d21f6d8-796f-4879-a2e1-314ddfcfb737", - "client_secret": "6hzvhNTsHPvTiUH/GUHVsFDt8b0BajZNox/iFI7iVJ8=", - "redirect_uris": ["http://localhost:8000/openid/callback/login/"], - "post_logout_redirect_uris": ["http://localhost:8000/openid/callback/logout/"], - } - }, + # "Azure Active Directory": { + # "srv_discovery_url": "https://sts.windows.net/9019caa7-f3ba-4261-8b4f-9162bdbe8cd1/", + # "behaviour": OIDC_DEFAULT_BEHAVIOUR, + # "client_registration": { + # "client_id": "0d21f6d8-796f-4879-a2e1-314ddfcfb737", + # "client_secret": "6hzvhNTsHPvTiUH/GUHVsFDt8b0BajZNox/iFI7iVJ8=", + # "redirect_uris": ["http://localhost:8000/openid/callback/login/"], + # "post_logout_redirect_uris": ["http://localhost:8000/openid/callback/logout/"], + # } + # }, # # No webfinger support, but OP information lookup and client registration # "xenosmilus": { # "srv_discovery_url": "https://xenosmilus2.umdc.umu.se:8091/", @@ -283,4 +315,4 @@ # }, } # -############################################################################### \ No newline at end of file +############################################################################### diff --git a/django_rp/urls.py b/django_rp/urls.py index 0dd4fbb..c36e6ad 100644 --- a/django_rp/urls.py +++ b/django_rp/urls.py @@ -1,21 +1,23 @@ -from django.conf.urls import patterns, include, url +from os import path + +from django.conf.urls import include, url from django.contrib import admin +from testapp.views import home, unprotected + admin.autodiscover() -from os import path BASEDIR = path.dirname(path.abspath(__file__)) -urlpatterns = patterns('', - # URLS for OpenId authentication - url(r'openid/', include('djangooidc.urls')), - - # Test URLs - url(r'^$', 'testapp.views.home', name='home'), - url(r'^unprotected$', 'testapp.views.unprotected', name='unprotected'), +urlpatterns = [ + # URLS for OpenId authentication + url(r'^openid/', include('djangooidc.urls')), - # Uncomment the next line to enable the admin: - url(r'^admin/', include(admin.site.urls)), + # Test URLs + url(r'^$', home, name='home'), + url(r'^unprotected$', unprotected, name='unprotected'), - ) + # Uncomment the next line to enable the admin: + url(r'^admin/', admin.site.urls), +] diff --git a/django_rp/wsgi.py b/django_rp/wsgi.py index 3cee9fa..91ff80e 100644 --- a/django_rp/wsgi.py +++ b/django_rp/wsgi.py @@ -16,6 +16,11 @@ import os import sys +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application + mage_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), os.path.pardir) if mage_path not in sys.path: sys.path.append(mage_path) @@ -28,10 +33,6 @@ # os.environ["DJANGO_SETTINGS_MODULE"] = "MAGE2.settings" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "MAGE.settings") -# This application object is used by any WSGI server configured to use this -# file. This includes Django's development server, if the WSGI_APPLICATION -# setting points here. -from django.core.wsgi import get_wsgi_application application = get_wsgi_application() # Apply WSGI middleware here. diff --git a/djangooidc/__init__.py b/djangooidc/__init__.py index 06c510a..f7e91fd 100644 --- a/djangooidc/__init__.py +++ b/djangooidc/__init__.py @@ -1,3 +1,3 @@ # coding: utf-8 -__version__ = '0.1.3' \ No newline at end of file +__version__ = '0.0.9' diff --git a/djangooidc/backends.py b/djangooidc/backends.py index 2391a9a..8eca698 100644 --- a/djangooidc/backends.py +++ b/djangooidc/backends.py @@ -1,22 +1,22 @@ # coding: utf-8 - from __future__ import unicode_literals -import datetime + from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend +from django.utils import timezone class OpenIdConnectBackend(ModelBackend): """ This backend checks a previously performed OIDC authentication. If it is OK and the user already exists in the database, it is returned. - If it is OK and user does not exist in the database, it is created and returned unless setting - OIDC_CREATE_UNKNOWN_USER is False. + If it is OK and user does not exist in the database, it is created and + returned unless setting OIDC_CREATE_UNKNOWN_USER is False. In all other cases, None is returned. """ - def authenticate(self, **kwargs): + def authenticate(self, request, **kwargs): user = None if not kwargs or 'sub' not in kwargs.keys(): return user @@ -26,8 +26,9 @@ def authenticate(self, **kwargs): if 'upn' in kwargs.keys(): username = kwargs['upn'] - # Some OP may actually choose to withhold some information, so we must test if it is present - openid_data = {'last_login': datetime.datetime.now()} + # Some OP may actually choose to withhold some information, so we must + # test if it is present + openid_data = {'last_login': timezone.now()} if 'first_name' in kwargs.keys(): openid_data['first_name'] = kwargs['first_name'] if 'given_name' in kwargs.keys(): @@ -45,15 +46,19 @@ def authenticate(self, **kwargs): # instead we use get_or_create when creating unknown users since it has # built-in safeguards for multiple threads. if getattr(settings, 'OIDC_CREATE_UNKNOWN_USER', True): - args = {UserModel.USERNAME_FIELD: username, 'defaults': openid_data, } + args = {UserModel.USERNAME_FIELD: username, + 'defaults': openid_data, } user, created = UserModel.objects.update_or_create(**args) if created: - user = self.configure_user(user) + user = self.configure_user(user, **kwargs) else: try: user = UserModel.objects.get_by_natural_key(username) except UserModel.DoesNotExist: - return None + try: + user = UserModel.objects.get(email=kwargs['email']) + except UserModel.DoesNotExist: + return None return user def clean_username(self, username): @@ -65,10 +70,11 @@ def clean_username(self, username): """ return username - def configure_user(self, user): + def configure_user(self, user, **kwargs): """ Configures a user after creation and returns the updated user. By default, returns the user unmodified. """ - return user \ No newline at end of file + user.set_unusable_password() + return user diff --git a/djangooidc/exceptions.py b/djangooidc/exceptions.py new file mode 100644 index 0000000..53b9cc4 --- /dev/null +++ b/djangooidc/exceptions.py @@ -0,0 +1,48 @@ + + +from djangooidc import status + + +class OIDCException(Exception): + """ + Base class for django oidc exceptions. + Subclasses should provide `.status_code` and `.default_detail` properties. + """ + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = 'A server error occurred.' + default_code = 'error' + + def __init__(self, detail=None, code=None): + if detail is None: + detail = self.default_detail + if code is None: + code = self.default_code + self.code = code + self.detail = detail + + def __str__(self): + return self.detail + + +class AuthenticationFailed(OIDCException): + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = 'Incorrect authentication credentials.' + default_code = 'authentication_failed' + + +# class ConfigurationError(OIDCException): +# status_code = status.HTTP_500_INTERNAL_SERVER_ERROR +# default_detail = 'Configuration error.' +# default_code = 'configuration_error' + + +# class ResponseError(OIDCException): +# status_code = status.HTTP_500_INTERNAL_SERVER_ERROR +# default_detail = 'Response error.' +# default_code = 'response_error' + + +# class CallbackStateMismatchError(OIDCException): +# status_code = status.HTTP_500_INTERNAL_SERVER_ERROR +# default_detail = 'Callback state mismatch error.' +# default_code = 'callback_state_error' diff --git a/djangooidc/oidc.py b/djangooidc/oidc.py index dd50341..c1a0d79 100644 --- a/djangooidc/oidc.py +++ b/djangooidc/oidc.py @@ -1,40 +1,51 @@ # coding: utf-8 +from __future__ import unicode_literals + +import logging +try: + from builtins import unicode as str +except ImportError: + pass from django.conf import settings -from oic.exception import MissingAttribute +from django.http import HttpResponseRedirect from oic import oic, rndstr -from oic.oauth2 import ErrorResponse -from oic.oic import ProviderConfigurationResponse, AuthorizationResponse -from oic.oic import RegistrationResponse -from oic.oic import AuthorizationRequest +from oic.exception import MissingAttribute +from oic.oauth2 import ErrorResponse, MissingEndpoint, ResponseError +from oic.oic import (AuthorizationRequest, AuthorizationResponse, + RegistrationResponse) +from oic.oic.message import ProviderConfigurationResponse from oic.utils.authn.client import CLIENT_AUTHN_METHOD +from oic.utils import keyio -__author__ = 'roland' +from . import exceptions -import logging -from django.http import HttpResponseRedirect +__author__ = 'roland' logger = logging.getLogger(__name__) default_ssl_check = getattr(settings, 'OIDC_VERIFY_SSL', True) -class OIDCError(Exception): +class OIDCError(exceptions.OIDCException): pass class Client(oic.Client): + def __init__(self, client_id=None, ca_certs=None, client_prefs=None, client_authn_method=None, keyjar=None, verify_ssl=True, behaviour=None): - oic.Client.__init__(self, client_id, ca_certs, client_prefs, - client_authn_method, keyjar, verify_ssl) + oic.Client.__init__(self, client_id=client_id, client_authn_method=client_authn_method, keyjar=keyjar, verify_ssl=verify_ssl, config=client_prefs) if behaviour: self.behaviour = behaviour + else: + self.behaviour = {} - def create_authn_request(self, session, acr_value=None, **kwargs): - session["state"] = rndstr() - session["nonce"] = rndstr() + def create_authn_request(self, session, # *, - let's not use this fancy py3 thing for compatibility + acr_value=None, extra_args=None): + session["state"] = rndstr(size=32) + session["nonce"] = rndstr(size=32) request_args = { "response_type": self.behaviour["response_type"], "scope": self.behaviour["scope"], @@ -43,22 +54,25 @@ def create_authn_request(self, session, acr_value=None, **kwargs): "redirect_uri": self.registration_response["redirect_uris"][0] } + if acr_value is None: + acr_value = self.behaviour.get('acr_value') + if acr_value is not None: request_args["acr_values"] = acr_value - request_args.update(kwargs) + if extra_args is not None: + request_args.update(extra_args) cis = self.construct_AuthorizationRequest(request_args=request_args) logger.debug("request: %s" % cis) url, body, ht_args, cis = self.uri_and_body(AuthorizationRequest, cis, method="GET", - request_args=request_args) - + request_args=request_args,) logger.debug("body: %s" % body) logger.info("URL: %s" % url) logger.debug("ht_args: %s" % ht_args) - resp = HttpResponseRedirect(url) + resp = HttpResponseRedirect(str(url)) if ht_args: for key, value in ht_args.items(): resp[key] = value @@ -67,14 +81,16 @@ def create_authn_request(self, session, acr_value=None, **kwargs): def callback(self, response, session): """ - This is the method that should be called when an AuthN response has been - received from the OP. + Should be called when an AuthN response has been received from the OP. :param response: The URL returned by the OP :return: """ - authresp = self.parse_response(AuthorizationResponse, response, - sformat="dict", keyjar=self.keyjar) + try: + authresp = self.parse_response(AuthorizationResponse, response, + sformat="dict", keyjar=self.keyjar) + except ResponseError as e: + return OIDCError(u"Response error: {}".format(e)) if isinstance(authresp, ErrorResponse): if authresp["error"] == "login_required": @@ -112,25 +128,56 @@ def callback(self, response, session): if isinstance(atresp, ErrorResponse): raise OIDCError("Invalid response %s." % atresp["error"]) session['id_token'] = atresp['id_token']._dict + if session['id_token']: + session['id_token_raw'] = getattr(self, 'id_token_raw', None) session['access_token'] = atresp['access_token'] - try: - session['refresh_token'] = atresp['refresh_token'] - except: - pass - - inforesp = self.do_user_info_request(state=authresp["state"], method="GET") + for k in ['refresh_token', 'expires_in']: + try: + session[k] = atresp[k] + except: + session[k] = "" + try: + inforesp = self.do_user_info_request( + state=authresp["state"], method="GET") - if isinstance(inforesp, ErrorResponse): - raise OIDCError("Invalid response %s." % inforesp["error"]) + if isinstance(inforesp, ErrorResponse): + raise OIDCError("Invalid response %s." % inforesp["error"]) - userinfo = inforesp.to_dict() + userinfo = inforesp.to_dict() - logger.debug("UserInfo: %s" % inforesp) + logger.debug("UserInfo: %s" % inforesp) + except MissingEndpoint as e: + logging.warning("Wrong OIDC provider implementation or configuration: {}; using token as userinfo source".format( + e + )) + userinfo = session.get('id_token', {}) return userinfo + def store_response(self, resp, info): + # makes raw ID token available for internal means + try: + import json + from oic.oic.message import AccessTokenResponse + if isinstance(resp, AccessTokenResponse): + info = json.loads(info) + self.id_token_raw = info['id_token'] + except Exception as e: + # fail silently if something is wrong + logger.exception(e) + + super(Client, self).store_response(resp, info) + + def __repr__(self): + return u"Client {} {} {}".format( + self.client_id, + self.client_prefs, + self.behaviour, + ) + class OIDCClients(object): + def __init__(self, config): """ @@ -157,7 +204,6 @@ def create_client(self, userid="", **kwargs): "provider_info"] :return: client instance """ - _key_set = set(kwargs.keys()) args = {} for param in ["verify_ssl"]: @@ -173,6 +219,15 @@ def create_client(self, userid="", **kwargs): except: verify_ssl = True + # Check to see if there is a keyset specified in the client_registration (if it is a client_registration type) + # This gets used if the authentication method is "private_key_jwt + if "client_registration" in _key_set: + if "keyset_jwk_file" in kwargs["client_registration"].keys(): + key_bundle = keyio.keybundle_from_local_file(kwargs["client_registration"]["keyset_jwk_file"],"jwk","sig") + key_jar =keyio.KeyJar(verify_ssl=verify_ssl) + key_jar.add_kb("",key_bundle) + args["keyjar"] = key_jar + client = self.client_cls(client_authn_method=CLIENT_AUTHN_METHOD, behaviour=kwargs["behaviour"], verify_ssl=verify_ssl, **args) @@ -194,23 +249,27 @@ def create_client(self, userid="", **kwargs): # Find the service that provides information about the OP issuer = client.wf.discovery_query(userid) # Gather OP information - _ = client.provider_config(issuer) + client.provider_config(issuer) # register the client - _ = client.register(client.provider_info["registration_endpoint"], - **kwargs["client_info"]) + client.register( + client.provider_info["registration_endpoint"], + **kwargs["client_info"] + ) elif _key_set == set(["client_info", "srv_discovery_url"]): # Ship the webfinger part # Gather OP information - _ = client.provider_config(kwargs["srv_discovery_url"]) + client.provider_config(kwargs["srv_discovery_url"]) # register the client - _ = client.register(client.provider_info["registration_endpoint"], - **kwargs["client_info"]) + client.register( + client.provider_info["registration_endpoint"], + **kwargs["client_info"] + ) elif _key_set == set(["provider_info", "client_info"]): client.handle_provider_config( ProviderConfigurationResponse(**kwargs["provider_info"]), kwargs["provider_info"]["issuer"]) - _ = client.register(client.provider_info["registration_endpoint"], - **kwargs["client_info"]) + client.register(client.provider_info["registration_endpoint"], + **kwargs["client_info"]) elif _key_set == set(["provider_info", "client_registration"]): client.handle_provider_config( ProviderConfigurationResponse(**kwargs["provider_info"]), @@ -218,15 +277,30 @@ def create_client(self, userid="", **kwargs): client.store_registration_info(RegistrationResponse( **kwargs["client_registration"])) elif _key_set == set(["srv_discovery_url", "client_registration"]): - _ = client.provider_config(kwargs["srv_discovery_url"]) - client.store_registration_info(RegistrationResponse( - **kwargs["client_registration"])) + try: + client.provider_config(kwargs["srv_discovery_url"]) + client.store_registration_info( + RegistrationResponse(**kwargs["client_registration"]) + ) + except Exception as e: + logger.error( + "Provider info discovery failed for %s - assume backend unworkable", + kwargs["srv_discovery_url"] + ) + logger.exception(e) else: raise Exception("Configuration error ?") return client def dynamic_client(self, userid): + try: + dyn = settings.OIDC_ALLOW_DYNAMIC_OP or False + except: + dyn = True + if not dyn: + raise KeyError("No dynamic clients allowed") + client = self.client_cls(client_authn_method=CLIENT_AUTHN_METHOD, verify_ssl=default_ssl_check) @@ -237,7 +311,8 @@ def dynamic_client(self, userid): # Gather OP information _pcr = client.provider_config(issuer) # register the client - _ = client.register(_pcr["registration_endpoint"], **self.config.OIDC_DYNAMIC_CLIENT_REGISTRATION_DATA) + client.register(_pcr["registration_endpoint"], ** + self.config.OIDC_DYNAMIC_CLIENT_REGISTRATION_DATA) try: client.behaviour.update(**self.config.OIDC_DEFAULT_BEHAVIOUR) except KeyError: @@ -259,4 +334,3 @@ def __getitem__(self, item): def keys(self): return self.client.keys() - diff --git a/djangooidc/status.py b/djangooidc/status.py new file mode 100644 index 0000000..c016b63 --- /dev/null +++ b/djangooidc/status.py @@ -0,0 +1,81 @@ +""" +Descriptive HTTP status codes, for code readability. + +See RFC 2616 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html +And RFC 6585 - http://tools.ietf.org/html/rfc6585 +And RFC 4918 - https://tools.ietf.org/html/rfc4918 +""" +from __future__ import unicode_literals + + +def is_informational(code): + return code >= 100 and code <= 199 + + +def is_success(code): + return code >= 200 and code <= 299 + + +def is_redirect(code): + return code >= 300 and code <= 399 + + +def is_client_error(code): + return code >= 400 and code <= 499 + + +def is_server_error(code): + return code >= 500 and code <= 599 + + +HTTP_100_CONTINUE = 100 +HTTP_101_SWITCHING_PROTOCOLS = 101 +HTTP_200_OK = 200 +HTTP_201_CREATED = 201 +HTTP_202_ACCEPTED = 202 +HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 +HTTP_204_NO_CONTENT = 204 +HTTP_205_RESET_CONTENT = 205 +HTTP_206_PARTIAL_CONTENT = 206 +HTTP_207_MULTI_STATUS = 207 +HTTP_300_MULTIPLE_CHOICES = 300 +HTTP_301_MOVED_PERMANENTLY = 301 +HTTP_302_FOUND = 302 +HTTP_303_SEE_OTHER = 303 +HTTP_304_NOT_MODIFIED = 304 +HTTP_305_USE_PROXY = 305 +HTTP_306_RESERVED = 306 +HTTP_307_TEMPORARY_REDIRECT = 307 +HTTP_400_BAD_REQUEST = 400 +HTTP_401_UNAUTHORIZED = 401 +HTTP_402_PAYMENT_REQUIRED = 402 +HTTP_403_FORBIDDEN = 403 +HTTP_404_NOT_FOUND = 404 +HTTP_405_METHOD_NOT_ALLOWED = 405 +HTTP_406_NOT_ACCEPTABLE = 406 +HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 +HTTP_408_REQUEST_TIMEOUT = 408 +HTTP_409_CONFLICT = 409 +HTTP_410_GONE = 410 +HTTP_411_LENGTH_REQUIRED = 411 +HTTP_412_PRECONDITION_FAILED = 412 +HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 +HTTP_414_REQUEST_URI_TOO_LONG = 414 +HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 +HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 +HTTP_417_EXPECTATION_FAILED = 417 +HTTP_422_UNPROCESSABLE_ENTITY = 422 +HTTP_423_LOCKED = 423 +HTTP_424_FAILED_DEPENDENCY = 424 +HTTP_428_PRECONDITION_REQUIRED = 428 +HTTP_429_TOO_MANY_REQUESTS = 429 +HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 +HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451 +HTTP_500_INTERNAL_SERVER_ERROR = 500 +HTTP_501_NOT_IMPLEMENTED = 501 +HTTP_502_BAD_GATEWAY = 502 +HTTP_503_SERVICE_UNAVAILABLE = 503 +HTTP_504_GATEWAY_TIMEOUT = 504 +HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 +HTTP_507_INSUFFICIENT_STORAGE = 507 +HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 diff --git a/djangooidc/tests.py b/djangooidc/tests.py new file mode 100644 index 0000000..4d7ce78 --- /dev/null +++ b/djangooidc/tests.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from djangooidc import * # NOQA +from djangooidc.backends import * # NOQA +from djangooidc.oidc import * # NOQA +from djangooidc.urls import * # NOQA +from djangooidc.views import * # NOQA + + +class SmokeImportTest(TestCase): + + def test_smoke_import(self): + # pass if imports on module level are fine + return diff --git a/djangooidc/urls.py b/djangooidc/urls.py index 9facf5b..349b6f9 100644 --- a/djangooidc/urls.py +++ b/djangooidc/urls.py @@ -1,12 +1,14 @@ # coding: utf-8 from django.conf.urls import url + from . import views urlpatterns = [ - url(r'^login$', views.openid, name='openid'), - url(r'^openid/(?P.+)$', views.openid, name='openid_with_op_name'), + url(r'^login/?$', views.openid, name='openid'), + url(r'^openid/(?P[^/]+)/?$', + views.openid, name='openid_with_op_name'), url(r'^callback/login/?$', views.authz_cb, name='openid_login_cb'), - url(r'^logout$', views.logout, name='logout'), + url(r'^logout/?$', views.logout, name='logout'), url(r'^callback/logout/?$', views.logout_cb, name='openid_logout_cb'), -] \ No newline at end of file +] diff --git a/djangooidc/views.py b/djangooidc/views.py index 7b2a132..e9a8480 100644 --- a/djangooidc/views.py +++ b/djangooidc/views.py @@ -1,34 +1,51 @@ # coding: utf-8 import logging -from urllib.parse import parse_qs -from urllib.parse import urlencode +try: + from urllib.parse import parse_qs, urlencode +except ImportError: + from urllib import urlencode + from urlparse import parse_qs + +from django import forms from django.conf import settings -from django.contrib.auth import logout as auth_logout, authenticate, login +from django.contrib.auth import logout as auth_logout +from django.contrib.auth import authenticate, login from django.contrib.auth.forms import AuthenticationForm -from django.contrib.auth.views import login as auth_login_view, logout as auth_logout_view -from django.shortcuts import redirect, render_to_response, resolve_url -from django.http import HttpResponse, HttpResponseRedirect -from django import forms -from django.template import RequestContext -from oic.oic.message import IdToken - +from django.contrib.auth import login as auth_login_view +from django.contrib.auth import logout as auth_logout_view +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import redirect, render, resolve_url from djangooidc.oidc import OIDCClients, OIDCError +from . import exceptions + logger = logging.getLogger(__name__) CLIENTS = OIDCClients(settings) -# Step 1: provider choice (form). Also - Step 2: redirect to OP. (Step 3 is OP business.) +def template_view_error(request, ctx, **kwargs): + return render(request, "djangooidc/error.html", ctx) + + +view_error_handler = template_view_error + + +# Step 1: provider choice (form). Also - Step 2: redirect to OP. (Step 3 +# is OP business.) + + class DynamicProvider(forms.Form): - hint = forms.CharField(required=True, label='OpenID Connect full login', max_length=250) + hint = forms.CharField( + required=True, label='OpenID Connect full login', max_length=250) def openid(request, op_name=None): client = None - request.session["next"] = request.GET["next"] if "next" in request.GET.keys() else "/" + request.session["next"] = request.GET[ + "next"] if "next" in request.GET.keys() else "/" try: dyn = settings.OIDC_ALLOW_DYNAMIC_OP or False except: @@ -46,9 +63,14 @@ def openid(request, op_name=None): else: ilform = AuthenticationForm() - # Try to find an OP client either from the form or from the op_name URL argument + # Try to find an OP client either from the form or from the op_name URL + # argument if request.method == 'GET' and op_name is not None: - client = CLIENTS[op_name] + try: + client = CLIENTS[op_name] + except KeyError as e: + logger.info(str(e)) + raise Http404("OIDC client not found") request.session["op"] = op_name if request.method == 'POST' and dyn: @@ -59,79 +81,107 @@ def openid(request, op_name=None): request.session["op"] = client.provider_info["issuer"] except Exception as e: logger.exception("could not create OOID client") - return render_to_response("djangooidc/error.html", {"error": e}) + return view_error_handler(request, {"error": e}) else: form = DynamicProvider() - # If we were able to determine the OP client, just redirect to it with an authentication request + # If we were able to determine the OP client, just redirect to it with an + # authentication request if client: try: - return client.create_authn_request(request.session) + acrvalue = None + if 'acr_value' in settings.OIDC_PROVIDERS[op_name]['client_registration']: + acrvalue = settings.OIDC_PROVIDERS[op_name]['client_registration']['acr_value'] + return client.create_authn_request(request.session, acr_value=acrvalue) except Exception as e: - return render_to_response("djangooidc/error.html", {"error": e}) + return view_error_handler(request, {"error": e}) # Otherwise just render the list+form. - return render_to_response(template_name, - {"op_list": [i for i in settings.OIDC_PROVIDERS.keys() if i], 'dynamic': dyn, - 'form': form, 'ilform': ilform, "next": request.session["next"]}, - context_instance=RequestContext(request)) + return render(request, template_name, + {"op_list": [i for i in settings.OIDC_PROVIDERS.keys() if i], 'dynamic': dyn, + 'form': form, 'ilform': ilform, "next": request.session["next"]}) # Step 4: analyze the token returned by the OP def authz_cb(request): + op = request.session.get("op") + if op is None: + # client session has been dropped or never existed - just ask him to do + # it again + return view_error_handler(request, { + "error": 'Wrong authentication; Please try again', + "callback": None + }) client = CLIENTS[request.session["op"]] query = None try: - query = parse_qs(request.META['QUERY_STRING']) - userinfo = client.callback(query, request.session) + query = parse_qs(request.GET.urlencode()) + callback_result = client.callback(query, request.session) + if isinstance(callback_result, OIDCError): + raise callback_result + userinfo = callback_result request.session["userinfo"] = userinfo user = authenticate(request=request, **userinfo) if user: login(request, user) - return redirect(request.session["next"]) + return redirect(request.session.get("next", "/")) else: - raise Exception('this login is not valid in this application') + raise exceptions.AuthenticationFailed( + 'this login is not valid in this application') except OIDCError as e: logging.getLogger('djangooidc.views.authz_cb').exception('Problem logging user in') - return render_to_response("djangooidc/error.html", {"error": e, "callback": query}) + # return render(request, "djangooidc/error.html", {"error": e, "callback": query}) + return view_error_handler(request, {"error": e, "callback": query}) def logout(request, next_page=None): - if not "op" in request.session.keys(): - return auth_logout_view(request, next_page) - - client = CLIENTS[request.session["op"]] - # User is by default NOT redirected to the app - it stays on an OP page after logout. - # Here we determine if a redirection to the app was asked for and is possible. + # Here we determine if a redirection to the app was asked for and is + # possible. if next_page is None and "next" in request.GET.keys(): next_page = request.GET['next'] if next_page is None and "next" in request.session.keys(): next_page = request.session['next'] + if next_page is None: + next_page = getattr(settings, 'LOGOUT_REDIRECT_URL', None) + + if "op" not in request.session.keys(): + return auth_logout_view(request, next_page) + + client = None + try: + client = CLIENTS[request.session["op"]] + except KeyError: + logger.info("OIDC client {} not found".format(request.session["op"])) + extra_args = {} - if "post_logout_redirect_uris" in client.registration_response.keys() and len( + if client is not None and "post_logout_redirect_uris" in client.registration_response.keys() and len( client.registration_response["post_logout_redirect_uris"]) > 0: if next_page is not None: # First attempt a direct redirection from OP to next_page next_page_url = resolve_url(next_page) - urls = [url for url in client.registration_response["post_logout_redirect_uris"] if next_page_url in url] + urls = [url for url in client.registration_response[ + "post_logout_redirect_uris"] if next_page_url in url] if len(urls) > 0: extra_args["post_logout_redirect_uri"] = urls[0] else: # It is not possible to directly redirect from the OP to the page that was asked for. - # We will try to use the redirection point - if the redirection point URL is registered that is. + # We will try to use the redirection point - if the redirection + # point URL is registered that is. next_page_url = resolve_url('openid_logout_cb') urls = [url for url in client.registration_response["post_logout_redirect_uris"] if next_page_url in url] if len(urls) > 0: extra_args["post_logout_redirect_uri"] = urls[0] else: - # Just take the first registered URL as a desperate attempt to come back to the application + # Just take the first registered URL as a desperate attempt + # to come back to the application extra_args["post_logout_redirect_uri"] = client.registration_response["post_logout_redirect_uris"][ 0] else: - # No post_logout_redirect_uris registered at the OP - no redirection to the application is possible anyway + # No post_logout_redirect_uris registered at the OP - no redirection to + # the application is possible anyway pass # Redirect client to the OP logout page @@ -141,41 +191,46 @@ def logout(request, next_page=None): # the user should be directed to the OIDC provider to logout after being # logged out here. - request_args = { - 'id_token_hint': request.session['access_token'], - 'state': request.session['state'], - } - request_args.update(extra_args) # should include the post_logout_redirect_uri - - # id_token iss is the token issuer, the url of the issuing server - # the full url works for the BOSS OIDC Provider, not tested on any other provider - url = request.session['id_token']['iss'] + "/protocol/openid-connect/logout" - url += "?" + urlencode(request_args) - return HttpResponseRedirect(url) - - # Looks like they are implementing back channel logout, without checking for - # support? - # http://openid.net/specs/openid-connect-backchannel-1_0.html#Backchannel - """ - request_args = None - if 'id_token' in request.session.keys(): - request_args = {'id_token': IdToken(**request.session['id_token'])} - res = client.do_end_session_request(state=request.session["state"], - extra_args=extra_args, request_args=request_args) - content_type = res.headers.get("content-type", "text/html") # In case the logout response doesn't set content-type (Seen with Keycloak) - resp = HttpResponse(content_type=content_type, status=res.status_code, content=res._content) - for key, val in res.headers.items(): - resp[key] = val - return resp - """ + if 'access_token' in request.session and 'state' in request.session: + # TODO: determine if OIDC was used to login by some better way + + request_args = { + 'id_token_hint': request.session['access_token'], + 'state': request.session['state'], + } + # should include the post_logout_redirect_uri + request_args.update(extra_args) + + url = client.provider_info['end_session_endpoint'] + url += "?" + urlencode(request_args) + return HttpResponseRedirect(url) + + # Looks like they are implementing back channel logout, without checking for + # support? + # http://openid.net/specs/openid-connect-backchannel-1_0.html#Backchannel + """ + request_args = None + if 'id_token' in request.session.keys(): + request_args = {'id_token': IdToken(**request.session['id_token'])} + res = client.do_end_session_request(state=request.session["state"], + extra_args=extra_args, request_args=request_args) + content_type = res.headers.get("content-type", "text/html") # In case the logout response doesn't set content-type (Seen with Keycloak) + resp = HttpResponse(content_type=content_type, status=res.status_code, content=res._content) + for key, val in res.headers.items(): + resp[key] = val + return resp + """ finally: - # Always remove Django session stuff - even if not logged out from OP. Don't wait for the callback as it may never come. + # Always remove Django session stuff - even if not logged out from OP. + # Don't wait for the callback as it may never come. auth_logout(request) if next_page: request.session['next'] = next_page + return HttpResponseRedirect(next_page) + def logout_cb(request): - """ Simple redirection view: after logout, just redirect to a parameter value inside the session """ + """Simple redirection view: after logout, just redirect to a parameter value inside the session""" next = request.session["next"] if "next" in request.session.keys() else "/" return redirect(next) diff --git a/manage.py b/manage.py index 051e905..1094ed5 100644 --- a/manage.py +++ b/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python -import os, sys +import os +import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_rp.settings") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index abe7238..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -setuptools>6 -django>=1.8,<1.9 -oic>=0.7.6 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 60115b1..0b55843 --- a/setup.py +++ b/setup.py @@ -27,20 +27,20 @@ history = open('HISTORY.rst').read().replace('.. :changelog:', '') setup( - name='django-oidc', + name='django-oidc-tf', version=version, description="""A Django OpenID Connect (OIDC) authentication backend""", long_description=readme + '\n\n' + history, - author='Marc-Antoine Gouillart', - author_email='marsu_pilami@msn.com', - url='https://github.com/marcanpilami/django-oidc', + author='Thomas Frössman', + author_email='thomasf@jossystem.se', + url='https://github.com/py-pa/django-oidc', packages=[ 'djangooidc', ], include_package_data=True, install_requires=[ - 'django>=1.8', - 'oic>=0.7.6', + 'django>=1.10', + 'oic>=0.10.0', ], license="Apache Software License", zip_safe=False, @@ -53,6 +53,6 @@ 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Natural Language :: English', - 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.5', ], ) diff --git a/testapp/views.py b/testapp/views.py index 3db2335..7c98fb7 100644 --- a/testapp/views.py +++ b/testapp/views.py @@ -1,11 +1,11 @@ from django.contrib.auth.decorators import login_required -from django.shortcuts import render_to_response +from django.shortcuts import render @login_required def home(request): # Old: opresult.mako - return render_to_response("testapp/result.html", {"userinfo": request.session['userinfo'] if 'userinfo' in request.session.keys() else None}) + return render(request, "testapp/result.html", {"userinfo": request.session['userinfo'] if 'userinfo' in request.session.keys() else None}) def unprotected(request): - return render_to_response("testapp/unprotected.html") + return render(request, "testapp/unprotected.html") diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..5e7ab30 --- /dev/null +++ b/tox.ini @@ -0,0 +1,21 @@ +[tox] + +envlist= + py{27,37}-django{110,120} + py{37}-django{22} + py{37,38}-django{30} + + +[testenv] + +deps = + django110: django>=1.10,<1.11 + django120: django>=1.11,<1.12 + django22: django>=2.2,<3.0 + django30: django>=3.0,<3.1 + coverage + mock + +commands = + python manage.py test +