commit 69800ee4bf99e67fbe57c96e99bcf39d5c71c33b Author: Guanzhong Chen Date: Wed Feb 19 21:28:43 2020 -0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b25d99f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Guanzhong Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f25ab1f --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# nginx-krbauth +LDAP + Kerberos authenticator for nginx's auth_request module. diff --git a/nginx_krbauth.py b/nginx_krbauth.py new file mode 100644 index 0000000..5700270 --- /dev/null +++ b/nginx_krbauth.py @@ -0,0 +1,143 @@ +import base64 +import binascii +import hashlib +import hmac +import logging +import os +import time +import socket +import struct +import sys +from urllib.parse import quote + +import gssapi +import ldap +from flask import Flask, request, redirect, url_for, Response +from gssapi.exceptions import GSSError, GeneralError + +app = Flask(__name__) +app.logger.setLevel(logging.INFO) + +timestamp = struct.Struct('!q') +hmac_digest = hashlib.sha512 +digest_size = hmac_digest().digest_size + +HMAC_KEY = os.environ['KRBAUTH_HMAC_KEY'].encode('utf-8') +DURATION = int(os.environ.get('KRBAUTH_KEY_DURATION', 3600)) +RANDOM_SIZE = int(os.environ.get('KRBAUTH_RANDOM_SIZE', 32)) +LDAP_SERVER = os.environ['KRBAUTH_LDAP_SERVER'] +LDAP_BIND_DN = os.environ.get('KRBAUTH_LDAP_BIND_DN') +LDAP_BIND_AUTHTOK = os.environ.get('KRBAUTH_LDAP_BIND_AUTHTOK') +LDAP_SEARCH_BASE = os.environ['KRBAUTH_LDAP_SEARCH_BASE'] + +GSSAPI_NAME = os.environ.get('KRBAUTH_GSSAPI_NAME') +if GSSAPI_NAME: + gssapi_name = gssapi.Name(GSSAPI_NAME, gssapi.NameType.hostbased_service) + gssapi_creds = gssapi.Credentials(name=gssapi_name, usage='accept') +else: + gssapi_creds = None + +COOKIE_SECURE = os.environ.get('KRBAUTH_SECURE_COOKIE', '1').lower() not in ('0', 'no') + + +class Context: + def __init__(self, ldap_group): + self.ldap_group = ldap_group + + @classmethod + def from_request(cls): + return cls(ldap_group=request.environ.get('KRBAUTH_LDAP_GROUP')) + + def bytes(self): + return ''.join([self.ldap_group]).encode('utf-8') + + +def make_cookie(context): + message = timestamp.pack(int(time.time()) + DURATION) + os.urandom(RANDOM_SIZE) + context.bytes() + signature = hmac.new(HMAC_KEY, message, hmac_digest).digest() + return base64.b64encode(signature + message) + + +def verify_cookie(cookie, context): + if not cookie: + return False + try: + data = base64.b64decode(cookie) + signature = data[:digest_size] + message = data[digest_size:] + ts = timestamp.unpack(message[:timestamp.size])[0] + except (struct.error, binascii.Error): + return False + if ts < time.time(): + return False + if not hmac.compare_digest(message[timestamp.size + RANDOM_SIZE:], context.bytes()): + return False + expected = hmac.new(HMAC_KEY, message, hashlib.sha512).digest() + return hmac.compare_digest(expected, signature) + + +def make_401(reason, context, auth='Negotiate', krb5_name=None): + app.logger.info('Returning unauthorized: %s (krb5_name=%s, ldap_group=%s)', reason, krb5_name, context.ldap_group) + return Response('''\ + + +401 Unauthorized + + +

401 Unauthorized

+
+
%s
+ + +''' % (reason,), status=401, headers={'WWW-Authenticate': auth}) + + +@app.route('/krbauth') +def auth(): + next = request.args.get('next', '/') + context = Context.from_request() + + if not request.headers.get('Authorization', '').startswith('Negotiate '): + return make_401('No Authorization header sent', context) + + try: + in_token = base64.b64decode(request.headers['Authorization'][10:]) + except binascii.Error: + return Response(status=400) + + try: + krb5_ctx = gssapi.SecurityContext(creds=gssapi_creds, usage='accept') + out_token = krb5_ctx.step(in_token) + + if not krb5_ctx.complete: + return make_401('Negotiation in progress', context, auth='Negotiate ' + base64.b64encode(out_token)) + + krb5_name = krb5_ctx._inquire(initiator_name=True).initiator_name + except (GSSError, GeneralError) as e: + return make_401(str(e), context) + + if LDAP_SERVER and context.ldap_group: + ldap_ctx = ldap.initialize(LDAP_SERVER) + if LDAP_BIND_DN and LDAP_BIND_AUTHTOK: + ldap_ctx.bind_s(LDAP_BIND_DN, LDAP_BIND_AUTHTOK, ldap.AUTH_SIMPLE) + ldap_filter = '(&(memberOf=%s)(krbPrincipalName=%s))' % (context.ldap_group, krb5_name) + try: + result = ldap_ctx.search_s(LDAP_SEARCH_BASE, ldap.SCOPE_SUBTREE, ldap_filter, ['cn']) + except ldap.NO_SUCH_OBJECT: + return make_401('Did not find LDAP group member', context, krb5_name=krb5_name) + if not result: + return make_401('Did not find LDAP group member', context, krb5_name=krb5_name) + app.logger.info('Authenticated as: %s, %s', krb5_name, result[0][0]) + else: + app.logger.info('Authenticated as: %s', krb5_name) + + resp = redirect(next, code=307) + resp.set_cookie('krbauth', make_cookie(context), secure=COOKIE_SECURE, httponly=True, samesite='Strict') + return resp + + +@app.route('/krbauth/check') +def check(): + if verify_cookie(request.cookies.get('krbauth'), Context.from_request()): + return Response(status=200) + return Response(status=401) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5144d26 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +import os + +from setuptools import setup + +with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f: + long_description = f.read() + +setup( + name='nginx_krbauth', + version='0.0.1', + py_modules=['nginx_krbauth'], + install_requires=['flask', 'gssapi', 'python-ldap'], + + author='quantum', + author_email='quantum2048@gmail.com', + url='https://github.com/quantum5/nginx-krbauth', + description="LDAP + Kerberos authenticator for nginx's auth_request module.", + long_description=long_description, + long_description_content_type='text/markdown', + keywords='ldap kerberos nginx', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Framework :: Flask', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', + 'Topic :: Security', + 'Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP', + ], +)