commit 778f0df7b77a26f61dad144124ae8f43895eef56 Author: Benjamin Dauvergne Date: Sat Aug 9 01:19:09 2014 +0200 first commit diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..7895400 --- /dev/null +++ b/COPYING @@ -0,0 +1,2 @@ +cmsplugin-blurp is entirely under the copyright of Entr'ouvert and distributed +under the license AGPLv3 or later. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..510b122 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,51 @@ +@Library('eo-jenkins-lib@master') import eo.Utils + +pipeline { + agent any + options { disableConcurrentBuilds() } + stages { + stage('Unit Tests') { + steps { + sh """ +rm -rf htmlcov* .coverage* coverage* junit*.xml +rm -rf venv +virtualenv -p python3 venv +. venv/bin/activate +pip install tox""" + sh './venv/bin/tox -rv' + } + post { + always { + script { + utils = new Utils() + utils.publish_coverage('coverage.xml') + utils.publish_coverage_native('index.html') + utils.publish_pylint('pylint.out') + } + sh './merge-junit-results.py junit-*.xml >junit.xml' + junit 'junit.xml' + } + } + } + stage('Packaging') { + steps { + script { + if (env.JOB_NAME == 'django-gssapi' && env.GIT_BRANCH == 'origin/master') { + sh 'sudo -H -u eobuilder /usr/local/bin/eobuilder django-gssapi' + } + } + } + } + } + post { + always { + script { + utils = new Utils() + utils.mail_notify(currentBuild, env, 'admin+jenkins-django-gssapi@entrouvert.com') + } + } + success { + cleanWs() + } + } +} diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7ea2d59 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include COPYING +include NEWS +include VERSION +include tox.ini +recursive-include tests *.py *.html +recursive-include sample *.py *.html README *.py.example +recursive-include src/django_gssapi/templates *.html +recursive-include src/django_gssapi/static *.js diff --git a/NEWS b/NEWS new file mode 100644 index 0000000..011894d --- /dev/null +++ b/NEWS @@ -0,0 +1,4 @@ +0.1 +=== + +- first release diff --git a/README b/README new file mode 100644 index 0000000..64c1a90 --- /dev/null +++ b/README @@ -0,0 +1,91 @@ +GSSAPI authentication for Django +================================== + +Provide GSSAPI (SPNEGO) authentication to Django applications. + +It's a rewrite of django-kerberos using python-gssapi. + +It's only tested with MIT Kerberos 5 using package k5test. + +Python 2 and 3, Django >1.8 are supported. + +Basic usage +=========== + +Add this to your project `urls.py`:: + + url('^auth/gssapi/', include('django_gssapi.urls')), + +And use the default authentication backend, by adding that to your `settings.py` file:: + + AUTHENTICATION_BACKENDS = ( + 'django_gssapi.backends.GSSAPIBackend', + ) + +View +==== + +django-gssapi provide a base LoginView that you can subclass to get the +behaviour your need, the main extension points are: + +- `challenge()` returns the 401 response with the challenge, you should override it + to show a template explaining the failure, +- `success(user)` it should log the given user and redirect to REDIRECT_FIELD_NAME, +- `get_service_name()` it should return a gssapi.Name for your service, by + default it returns None, so GSSAPI will match any name available (for example + with Kerberos it will match any name in your keytab, like + @HTTP/my.domain.com@). + +Settings +======== + +To make your application use GSSAPI as its main login method:: + + LOGIN_URL = 'gssapi-login' + +Your application need an environment where the GSSAPI mechanism like Kerberos +will work, for Kerberos it means having a default keytab of creating one and +setting its path in KRB5_KTNAME or you can use `GSSAPI_STORE` with MIT Kerberos +5 and credential store extension to indicate a keytab:: + + GSSAPI_STORE = {'keytab': 'FILE:/var/lib/mykeytab'} + +You can also force a GSSAPI name for you service with:: + + import gssapi + + GSSAPI_NAME = gssapi.Name('HTTP/my.service.com', gssapi.MechType.hostbased_service) + +GSSAPI authentication backend +============================= + +A dummy backend is provided in `django_gssapi.backends.GSSAPIBackend` it looks +up user with the same username as the GSSAPI name. You should implement it for +your use case. + +A custom authentication backend must have the following signature:: + + class CustomGSSAPIBackend(object): + def authenticate(self, request, gssapi_name): + pass + +The parameter `gssapi_name` is a `gssapi.Name` object, it can be casted to +string to get the raw name. + +Kerberos username/password backend +================================== + +If your users does not have their browser configured for SPNEGO HTTP +authentication you can also provide a classic login/password form which check +passwords using Kerberos. For this use +`django_gssapi.backends.KerberosPasswordBackend`, the username is used as the +raw principal name. + + +django-rest-framework authentication backend +============================================ + +To authenticate users with GSSAPI you can use +`django_gssapi.drf.GSSAPIAuthentication`, it uses the configured GSSAPI +authentication backend to find an user and returns the GSSAPI name in +`request.auth`. diff --git a/changelog b/changelog new file mode 100644 index 0000000..3320fd1 --- /dev/null +++ b/changelog @@ -0,0 +1,5 @@ +python-django-gssapi (0.1) stable; urgency=low + + * New upstream release + + -- Benjamin Dauvergne Thu, 23 Aug 2019 19:00:29 +0100 diff --git a/compat b/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/compat @@ -0,0 +1 @@ +9 diff --git a/control b/control new file mode 100644 index 0000000..9bf9e5e --- /dev/null +++ b/control @@ -0,0 +1,33 @@ +Source: python-django-gssapi +Maintainer: Benjamin Dauvergne +Section: python +Priority: optional +Build-Depends: dh-python, + debhelper (>= 9), + python-setuptools (>= 0.6b3), + python-all (>= 2.6), + python-django (>= 1.8), + python3-all, + python3-setuptools, + python3-django (>= 1.8) +Standards-Version: 3.9.1 +X-Python-Version: >= 2.7 +X-Python3-Version: >= 3.4 + +Package: python-django-gssapi +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python-six, + python-django (>= 1.8), + python-gssapi +Description: GSSAPI authentication for Django + +Package: python3-django-gssapi +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python3-six, + python3-django (>= 1.8), + python3-gssapi +Description: GSSAPI authentication for Django + . + This is the Python 3 version of the package. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..3320fd1 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +python-django-gssapi (0.1) stable; urgency=low + + * New upstream release + + -- Benjamin Dauvergne Thu, 23 Aug 2019 19:00:29 +0100 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ec63514 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +9 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..9bf9e5e --- /dev/null +++ b/debian/control @@ -0,0 +1,33 @@ +Source: python-django-gssapi +Maintainer: Benjamin Dauvergne +Section: python +Priority: optional +Build-Depends: dh-python, + debhelper (>= 9), + python-setuptools (>= 0.6b3), + python-all (>= 2.6), + python-django (>= 1.8), + python3-all, + python3-setuptools, + python3-django (>= 1.8) +Standards-Version: 3.9.1 +X-Python-Version: >= 2.7 +X-Python3-Version: >= 3.4 + +Package: python-django-gssapi +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python-six, + python-django (>= 1.8), + python-gssapi +Description: GSSAPI authentication for Django + +Package: python3-django-gssapi +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python3-six, + python3-django (>= 1.8), + python3-gssapi +Description: GSSAPI authentication for Django + . + This is the Python 3 version of the package. diff --git a/debian/pydist-overrides b/debian/pydist-overrides new file mode 100644 index 0000000..07b01f8 --- /dev/null +++ b/debian/pydist-overrides @@ -0,0 +1,2 @@ +django python-django +gssapi python-gssapi diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..0037245 --- /dev/null +++ b/debian/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f +export PYBUILD_NAME=django-gssapi + +%: + dh $@ --with python2,python3 --buildsystem=pybuild + + diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/merge-junit-results.py b/merge-junit-results.py new file mode 100755 index 0000000..a50bc7f --- /dev/null +++ b/merge-junit-results.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# +# Corey Goldberg, Dec 2012 +# + +import os +import sys +import xml.etree.ElementTree as ET + + +"""Merge multiple JUnit XML files into a single results file. +Output dumps to sdtdout. +example usage: + $ python merge_junit_results.py results1.xml results2.xml > results.xml +""" + + +def main(): + args = sys.argv[1:] + if not args: + usage() + sys.exit(2) + if '-h' in args or '--help' in args: + usage() + sys.exit(2) + merge_results(args[:]) + + +def merge_results(xml_files): + failures = 0 + tests = 0 + errors = 0 + time = 0.0 + cases = [] + + for file_name in xml_files: + tree = ET.parse(file_name) + test_suite = tree.getroot() + failures += int(test_suite.attrib['failures']) + tests += int(test_suite.attrib['tests']) + errors += int(test_suite.attrib['errors']) + time += float(test_suite.attrib['time']) + name = test_suite.attrib.get('name', '') + for child in test_suite.getchildren(): + child.attrib['classname'] = '%s-%s' % (name, child.attrib.get('classname', '')) + cases.append(test_suite.getchildren()) + + new_root = ET.Element('testsuite') + new_root.attrib['failures'] = '%s' % failures + new_root.attrib['tests'] = '%s' % tests + new_root.attrib['errors'] = '%s' % errors + new_root.attrib['time'] = '%s' % time + for case in cases: + new_root.extend(case) + new_tree = ET.ElementTree(new_root) + ET.dump(new_tree) + + +def usage(): + this_file = os.path.basename(__file__) + print('Usage: %s results1.xml results2.xml' % this_file) + + +if __name__ == '__main__': + main() diff --git a/pylint.sh b/pylint.sh new file mode 100755 index 0000000..d7295cc --- /dev/null +++ b/pylint.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +set -e -x +env +if [ -f /var/lib/jenkins/pylint.django.rc ]; then + PYLINT_RC=/var/lib/jenkins/pylint.django.rc +elif [ -f pylint.django.rc ]; then + PYLINT_RC=pylint.django.rc +else + echo No pylint RC found + exit 0 +fi +pylint -f parseable --rcfile ${PYLINT_RC} "$@" | tee pylint.out || /bin/true diff --git a/rules b/rules new file mode 100755 index 0000000..0037245 --- /dev/null +++ b/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f +export PYBUILD_NAME=django-gssapi + +%: + dh $@ --with python2,python3 --buildsystem=pybuild + + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..437dd15 --- /dev/null +++ b/setup.py @@ -0,0 +1,77 @@ +#! /usr/bin/env python + +import subprocess +import os + +from setuptools import setup, find_packages +from setuptools.command.sdist import sdist + + +class eo_sdist(sdist): + def run(self): + print("creating VERSION file") + if os.path.exists('VERSION'): + os.remove('VERSION') + version = get_version() + version_file = open('VERSION', 'w') + version_file.write(version) + version_file.close() + sdist.run(self) + print("removing VERSION file") + if os.path.exists('VERSION'): + os.remove('VERSION') + + +def get_version(): + '''Use the VERSION, if absent generates a version with git describe, if not + tag exists, take 0.0- and add the length of the commit log. + ''' + if os.path.exists('VERSION'): + with open('VERSION', 'r') as v: + return v.read() + if os.path.exists('.git'): + p = subprocess.Popen(['git', 'describe', '--dirty=.dirty','--match=v*'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + result = p.communicate()[0] + if p.returncode == 0: + result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v + if '-' in result: # not a tagged version + real_number, commit_count, commit_hash = result.split('-', 2) + version = '%s.post%s+%s' % (real_number, commit_count, commit_hash) + else: + version = result + return version + else: + return '0.0.post%s' % len( + subprocess.check_output( + ['git', 'rev-list', 'HEAD']).splitlines()) + return '0.0' + +setup(name="django-gssapi", + version=get_version(), + license="AGPLv3 or later", + description="GSSAPI authentication for Django", + long_description=open('README').read(), + url="http://dev.entrouvert.org/projects/authentic/", + author="Entr'ouvert", + author_email="info@entrouvert.org", + maintainer="Benjamin Dauvergne", + maintainer_email="bdauvergne@entrouvert.com", + packages=find_packages('src'), + zip_safe=False, + include_package_data=True, + install_requires=[ + 'six', + 'django>1.8', + 'gssapi', + ], + package_dir={ + '': 'src', + }, + package_data={ + 'django_gssapi': [ + 'templates/django_gssapi/*.html', + 'static/js/*.js', + ], + }, + cmdclass={'sdist': eo_sdist}) diff --git a/source/format b/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/src/django_gssapi/__init__.py b/src/django_gssapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_gssapi/backends.py b/src/django_gssapi/backends.py new file mode 100644 index 0000000..9432ef4 --- /dev/null +++ b/src/django_gssapi/backends.py @@ -0,0 +1,78 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from __future__ import unicode_literals + +import logging +import warnings + + +from django.contrib.auth import get_user_model +from django.utils.encoding import force_bytes + +import gssapi +import gssapi.exceptions +import gssapi.raw as gb + + +logger = logging.getLogger('django_gssapi') + + +class GSSAPIBackend(object): + def authenticate(self, request, gssapi_name): + warnings.warn('example backend do not use in production!') + User = get_user_model() + try: + user = User.objects.get(username=str(gssapi_name)) + except User.DoesNotExist: + logger.debug('GSSAPI no user found for name %s', gssapi_name) + else: + if user.is_active: + return user + + +class KerberosPasswordBackend(object): + def principal_from_user(self, user): + return user.username + + def authenticate(self, request, username=None, password=None, **kwargs): + '''Verify username and password using Kerberos''' + warnings.warn('Kerberos: example backend do not use in production!') + + User = get_user_model() + + if username is None: + username = kwargs.get(User.USERNAME_FIELD) + try: + user = User._default_manager.get_by_natural_key(username) + except User.DoesNotExist: + logger.debug('Kerberos: no user for username %s', username) + return + else: + if not user.is_active: + return + + principal = self.principal_from_user(user) + + try: + name = gb.import_name(force_bytes(principal), gb.NameType.kerberos_principal) + if gb.acquire_cred_with_password(name, force_bytes(password)): + if not user.check_password(password): + user.set_password(password) + user.save() + return user + except gssapi.exceptions.GSSError as e: + logger.debug('Kerberos: password check failed for principal %s: %s', principal, e) diff --git a/src/django_gssapi/drf.py b/src/django_gssapi/drf.py new file mode 100644 index 0000000..9393dfc --- /dev/null +++ b/src/django_gssapi/drf.py @@ -0,0 +1,68 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.conf import settings + +import rest_framework.authentication + +from . import utils + +__all__ = ['GSSAPIAuthentication', 'add_gssapi_mutual_auth'] + + +class GSSAPIAuthentication(rest_framework.authentication.BaseAuthentication): + def get_gssapi_store(self): + return getattr(settings, 'GSSAPI_STORE', None) + + def get_gssapi_name(self): + # force a service name, ex. : + # server_name = 'HTTP@%s' % self.request.get_host() + # return gssapi.Name(server_name, name_type=gssapi.NameType.hostbased_service) + # without one, any service name in keytab will do + return getattr(settings, 'GSSAPI_NAME', None) + + def authenticate(self, request): + try: + user, gss_name, token = utils.negotiate_and_auth( + request, + name=self.get_gssapi_name(), + store=self.get_gssapi_store()) + except utils.NegotiateContinue as e: + token = e.token + + if user is None: + return None + + # DRF authentication does not allow to implement + # natively mutual GSSAPI authentication as we + # cannot modify the response, if needed views can retrieve the result + # token and set it on their response + request._drf_gssapi_token = token + return user, gss_name + + def authenticate_header(self, request): + return utils.authenticate_header(token=get_gssapi_token(request)) + + +def add_gssapi_mutual_auth(request, response): + token = get_gssapi_token(request) + if token is not None: + response['WWW-Authenticate'] = utils.authenticate_header(token=token) + + +def get_gssapi_token(request): + return getattr(request, '_drf_gssapi_token', None) diff --git a/src/django_gssapi/models.py b/src/django_gssapi/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/django_gssapi/urls.py b/src/django_gssapi/urls.py new file mode 100644 index 0000000..f39e1fb --- /dev/null +++ b/src/django_gssapi/urls.py @@ -0,0 +1,23 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^login/$', views.login, name='gssapi-login'), +] diff --git a/src/django_gssapi/utils.py b/src/django_gssapi/utils.py new file mode 100644 index 0000000..daf77a1 --- /dev/null +++ b/src/django_gssapi/utils.py @@ -0,0 +1,89 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from __future__ import unicode_literals + +import base64 +import logging + +import gssapi +import gssapi.exceptions + +from django import http +from django.contrib.auth import authenticate + +logger = logging.getLogger('django_kerberos') + + +class NegotiateContinue(Exception): + def __init__(self, token): + self.token = token + + +def negotiate(request, name=None, store=None): + '''Try to authenticate the user using SPNEGO and Kerberos''' + + if name: + logger.debug(u'GSSAPI negotiate using name %s', name) + + try: + server_creds = gssapi.Credentials(usage='accept', name=name, store=store) + except gssapi.exceptions.GSSError as e: + logging.debug('GSSAPI credentials failure: %s', e) + return None, None + + if not request.META.get('HTTP_AUTHORIZATION', '').startswith('Negotiate '): + return None, None + + authstr = request.META['HTTP_AUTHORIZATION'][10:] + try: + in_token = base64.b64decode(authstr) + except ValueError: + return None, None + + server_ctx = gssapi.SecurityContext(creds=server_creds, usage='accept') + try: + out_token = server_ctx.step(in_token) + except gssapi.exceptions.GSSError as e: + logging.debug('GSSAPI security context failure: %s', e) + if not server_ctx.complete: + raise NegotiateContinue(out_token) + + return server_ctx.initiator_name, out_token + + +def negotiate_and_auth(request, name=None, store=None): + gssapi_name, token = negotiate(request, name=name, store=store) + + if gssapi_name is None: + return None, None, token + + return authenticate(gssapi_name=gssapi_name), gssapi_name, token + + +def challenge_response(): + '''Send negotiate challenge''' + response = http.HttpResponse('GSSAPI authentication failed', status=401) + response_add_www_authenticate(response) + return response + + +def authenticate_header(token=None): + return 'Negotiate%s' % (' ' + base64.b64encode(token).decode('ascii') if token else '') + + +def response_add_www_authenticate(response, token=None): + response['WWW-Authenticate'] = authenticate_header(token) diff --git a/src/django_gssapi/views.py b/src/django_gssapi/views.py new file mode 100644 index 0000000..1bf33a7 --- /dev/null +++ b/src/django_gssapi/views.py @@ -0,0 +1,102 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from __future__ import unicode_literals + +import base64 +import logging + +from django import http +from django.conf import settings +from django.utils.http import is_safe_url +from django.views.generic.base import View + +from django.contrib.auth import authenticate, login as auth_login, REDIRECT_FIELD_NAME + +from . import utils + +logger = logging.getLogger('django_kerberos') + + +class NegotiateFailed(Exception): + pass + + +class LoginView(View): + redirect_field_name = REDIRECT_FIELD_NAME + + def get_success_url_allowed_hosts(self): + return {self.request.get_host()} + + def get_redirect_url(self): + """Return the user-originating redirect URL if it's safe.""" + redirect_to = self.request.POST.get( + self.redirect_field_name, + self.request.GET.get(self.redirect_field_name, '') + ) + url_is_safe = is_safe_url( + url=redirect_to, + allowed_hosts=self.get_success_url_allowed_hosts(), + require_https=self.request.is_secure(), + ) + return redirect_to if url_is_safe else settings.LOGIN_REDIRECT_URL + + def get_gssapi_store(self): + return getattr(settings, 'GSSAPI_STORE', None) + + def get_gssapi_name(self): + # force a service name, ex. : + # server_name = 'HTTP@%s' % self.request.get_host() + # return gssapi.Name(server_name, name_type=gssapi.NameType.hostbased_service) + # without one, any service name in keytab will do + return getattr(settings, 'GSSAPI_NAME', None) + + def challenge(self): + '''Send negotiate challenge''' + return utils.challenge_response() + + def success(self, user): + '''Do something with the user we found''' + auth_login(self.request, user) + return http.HttpResponseRedirect(self.get_redirect_url()) + + def negotiate(self): + '''Try to authenticate the user using SPNEGO''' + + try: + user, gss_name, token = utils.negotiate_and_auth( + self.request, + name=self.get_gssapi_name(), + store=self.get_gssapi_store()) + except utils.NegotiateContinue as e: + token = e.token + + if user is None: + response = self.challenge() + else: + logger.debug('GSSAPI found user %s for name %s', user, gss_name) + response = self.success(user) + + utils.response_add_www_authenticate(response, token) + return response + + def get(self, request, *args, **kwargs): + return self.negotiate() + + def post(self, request, *args, **kwargs): + return self.negotiate() + +login = LoginView.as_view() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..52d26bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,62 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from __future__ import unicode_literals + +import base64 +import os +import pytest + +import gssapi + +import k5test +import k5test._utils + +from django.contrib.auth import get_user_model + +User = get_user_model() + + +@pytest.fixture +def k5env(): + k5realm = k5test.K5Realm() + old_environ = os.environ.copy() + try: + + os.environ.update(k5realm.env) + k5realm.http_princ = 'HTTP/testserver@%s' % k5realm.realm + k5realm.addprinc(k5realm.http_princ) + k5realm.extract_keytab(k5realm.http_princ, k5realm.keytab) + + def spnego(): + service_name = gssapi.Name(k5realm.http_princ) + service_name.canonicalize(gssapi.MechType.kerberos) + + # first attempt + ctx = gssapi.SecurityContext(usage='initiate', name=service_name) + return 'Negotiate %s' % base64.b64encode(ctx.step()).decode('ascii') + k5realm.spnego = spnego + yield k5realm + finally: + os.environ.clear() + os.environ.update(old_environ) + k5realm.stop() + + +@pytest.fixture +def user_princ(k5env): + return User.objects.create(username=k5env.user_princ) + diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..e3b7da0 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,53 @@ +import django +import os.path + +DATABASES = { + 'default': { + 'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'), + } +} + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(os.path.dirname(__file__), 'templates')], + 'APP_DIRS': True, + }, +] + +if django.VERSION < (1, 10): + MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.middleware.http.ConditionalGetMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ) +else: + 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', + 'django.contrib.messages.middleware.MessageMiddleware', + ] + +INSTALLED_APPS = ( + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django_gssapi', +) +TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), 'templates'),) +ROOT_URLCONF = 'django_gssapi.urls' +SECRET_KEY = 'xxx' + +AUTHENTICATION_BACKENDS = ( + 'django_gssapi.backends.GSSAPIBackend', + 'django_gssapi.backends.KerberosPasswordBackend', +) diff --git a/tests/templates/base.html b/tests/templates/base.html new file mode 100644 index 0000000..4275f80 --- /dev/null +++ b/tests/templates/base.html @@ -0,0 +1,2 @@ +{% block content %} +{% endblock %} diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..65dc22e --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,31 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import os + +from django.contrib.auth import get_user_model, authenticate + +User = get_user_model() + + +def test_kerberos_password(k5env, db): + user = User.objects.create(username=k5env.user_princ) + + k5env.run(['kdestroy']) + + assert authenticate(username=k5env.user_princ, password='nogood') is None + assert authenticate(username=k5env.user_princ, password=k5env.password('user')) == user + assert not os.path.exists(k5env.ccache) diff --git a/tests/test_drf.py b/tests/test_drf.py new file mode 100644 index 0000000..3656242 --- /dev/null +++ b/tests/test_drf.py @@ -0,0 +1,59 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from django_gssapi.drf import GSSAPIAuthentication, add_gssapi_mutual_auth + + +class RestView(APIView): + authentication_classes = [GSSAPIAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + response = Response({ + 'user': str(request.user), + 'auth': str(request.auth), + }) + add_gssapi_mutual_auth(request, response) + return response + +view = RestView.as_view() + + +def test_gssapi_authentication_no_auth(k5env, db, rf): + request = rf.get('/') + response = view(request) + assert response.status_code == 401 + assert response['WWW-Authenticate'] == 'Negotiate' + + +def test_gssapi_authentication_no_user(k5env, db, rf): + request = rf.get('/', HTTP_AUTHORIZATION=k5env.spnego()) + response = view(request) + assert response.status_code == 401 + assert response['WWW-Authenticate'] == 'Negotiate' + + +def test_gssapi_authentication_passing(k5env, db, rf, user_princ): + request = rf.get('/', HTTP_AUTHORIZATION=k5env.spnego()) + response = view(request) + assert response.status_code == 200 + assert response.data['user'] == k5env.user_princ + assert response.data['auth'] == k5env.user_princ + assert response['WWW-Authenticate'].startswith('Negotiate ') diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..ae71627 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,49 @@ +# django-gssapi - SPNEGO/Kerberos authentication for Django applications +# Copyright (C) 2014-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging + +import gssapi + +from django.contrib.auth import get_user_model + +User = get_user_model() + + +def test_login(k5env, client, caplog, db, settings): + caplog.set_level(logging.DEBUG) + + response = client.get('/login/') + assert response.status_code == 401 + + response = client.get('/login/', HTTP_AUTHORIZATION=k5env.spnego()) + + assert response.status_code == 401 + assert '_auth_user_id' not in client.session + + # create an user... + User.objects.create(username=k5env.user_princ) + + # and retry. + response = client.get('/login/', HTTP_AUTHORIZATION=k5env.spnego()) + + assert response.status_code == 302 + assert client.session['_auth_user_id'] + + # break service name resolution + settings.GSSAPI_NAME = gssapi.Name('HTTP@localhost', gssapi.NameType.hostbased_service) + response = client.get('/login/', HTTP_AUTHORIZATION=k5env.spnego()) + assert response.status_code == 401 diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0114243 --- /dev/null +++ b/tox.ini @@ -0,0 +1,45 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/django-gssapi/{env:BRANCH_NAME:} +envlist = py27-coverage-dj111-{stretch,},py3-coverage-{dj111,dj20,djlast},pylint + +[testenv] +whitelist_externals = + /bin/mv + /bin/rm +setenv = + DJANGO_SETTINGS_MODULE=settings + PYTHONPATH=tests + coverage: COVERAGE=--cov-branch --cov-append --cov=src/ --cov-report=html --cov-report=xml --cov-config .coveragerc + DB_ENGINE=django.db.backends.sqlite3 +usedevelop = true +deps = + stretch: gssapi<1.2.3 + dj18: django>1.8,<1.9 + dj18: django-tables2<1.1 + dj111: django<2.0 + dj20: django<2.1 + djlast: django + pytest<4.2 + pytest-mock + pytest-django + pytest-cov + k5test + django-rest-framework +commands = + py.test {env:COVERAGE:} -o junit_suite_name={envname} --junit-xml=junit-{envname}.xml {posargs:tests} + +[testenv:pylint] +deps = + pylint<1.8 + pylint-django<0.8.1 +commands = + pylint: ./pylint.sh src/django_gssapi/ + +[pytest] +filterwarnings = + ignore:Kerberos. example backend.*