From 778f0df7b77a26f61dad144124ae8f43895eef56 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Sat, 9 Aug 2014 01:19:09 +0200 Subject: [PATCH] first commit --- COPYING | 2 + Jenkinsfile | 51 +++++++++++++++++ MANIFEST.in | 8 +++ NEWS | 4 ++ README | 91 ++++++++++++++++++++++++++++++ changelog | 5 ++ compat | 1 + control | 33 +++++++++++ debian/changelog | 5 ++ debian/compat | 1 + debian/control | 33 +++++++++++ debian/pydist-overrides | 2 + debian/rules | 7 +++ debian/source/format | 1 + merge-junit-results.py | 65 ++++++++++++++++++++++ pylint.sh | 13 +++++ rules | 7 +++ setup.py | 77 +++++++++++++++++++++++++ source/format | 1 + src/django_gssapi/__init__.py | 0 src/django_gssapi/backends.py | 78 ++++++++++++++++++++++++++ src/django_gssapi/drf.py | 68 +++++++++++++++++++++++ src/django_gssapi/models.py | 0 src/django_gssapi/urls.py | 23 ++++++++ src/django_gssapi/utils.py | 89 +++++++++++++++++++++++++++++ src/django_gssapi/views.py | 102 ++++++++++++++++++++++++++++++++++ tests/conftest.py | 62 +++++++++++++++++++++ tests/settings.py | 53 ++++++++++++++++++ tests/templates/base.html | 2 + tests/test_backends.py | 31 +++++++++++ tests/test_drf.py | 59 ++++++++++++++++++++ tests/test_views.py | 49 ++++++++++++++++ tests/urls.py | 0 tox.ini | 45 +++++++++++++++ 34 files changed, 1068 insertions(+) create mode 100644 COPYING create mode 100644 Jenkinsfile create mode 100644 MANIFEST.in create mode 100644 NEWS create mode 100644 README create mode 100644 changelog create mode 100644 compat create mode 100644 control create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/pydist-overrides create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100755 merge-junit-results.py create mode 100755 pylint.sh create mode 100755 rules create mode 100755 setup.py create mode 100644 source/format create mode 100644 src/django_gssapi/__init__.py create mode 100644 src/django_gssapi/backends.py create mode 100644 src/django_gssapi/drf.py create mode 100644 src/django_gssapi/models.py create mode 100644 src/django_gssapi/urls.py create mode 100644 src/django_gssapi/utils.py create mode 100644 src/django_gssapi/views.py create mode 100644 tests/conftest.py create mode 100644 tests/settings.py create mode 100644 tests/templates/base.html create mode 100644 tests/test_backends.py create mode 100644 tests/test_drf.py create mode 100644 tests/test_views.py create mode 100644 tests/urls.py create mode 100644 tox.ini 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.*