Skip to main content

A Python package for authenticating users (more formally called principals) via a Microsoft Active Directory (AD) domain controller.

Project description

Py-AD-Principal

A Python package for authenticating users (more formally called principals) via a Microsoft Active Directory (AD) domain controller. This module supports Linux-based services and service principals, and is tested against an AD controller configured as described in the official Samba 4 documentation.

Prerequisites

This module depends on the Python GSSAPI module for integration with the AD Key Distribution Center (KDC). It expects a pre-configured and operational Kerberos 5 environment tested against the intended AD domain. Please see the linked Samba documentation for detailed instructions.

Assuming you've met this requirement and Python GSSAPI is not already installed, pip will automatically install it as a dependency. When this occurs, it attempts to build a native wheel that links against the system's gssapi. For this to succeed, libkrb5's development package must be installed. On Ubuntu 24.04 LTS, this may be done via apt as follows:

$ sudo apt install libkrb5-dev

On Fedora 40+, the equivalent command is:

$ sudo dnf install krb5-devel

If you've gotten this far, you are now be ready to install the package.

Installation

The easiest way to install package is via pip.

$ pip install py-ad-principal

Alternatively, you may clone the git repo and install from there. This method is only recommended for those interested in modifying the source or submitting pull requests.

$ git clone https://github.com/miscellanea-io/py-ad-principal.git
$ pip install .

Configuration

This module is configured via a TOML file named py_ad_principal.toml. Place it in the directory from which the script is launched. You may specify an alternative name or location by providing an absolute path to it when initializing an instance of the ActiveDirectoryConf class.

An example file is provided below.

[krb5]
service = "HTTP"
hostname = "{Your AD domain controller's host name}"
keytab = "{absolute path to a keytab file containing your SPN's credentials}"

[ldap]
server = "{your AD domain controller's host name}"
use_tls = true
search_base = "{CN=Users,DC=Your,DC=Active Directory,DC=Domain}"
nested_groups = false
anonymous_bind = false
bind_user = "{Bind user account in sAMAccountName format (e.g., DOMAIN\\Account)}"
bind_password = "{Bind account password}"

You may also provide (or override) configuration values via environment variables. They must be named according to the following specification: AD_{$TOML TABLE}_{$TOML KEY}. For example, to disable TLS for LDAP (and switch from port TCP/636 to TCP/389) execute the following command prior to running your script:

$ export AD_LDAP_USE_TLS=False

Note that all names are converted to upper case when passed as environment variables!

Quick Start

This module was developed to scratch an itch: a need to support browser-based SSO to a Flask application, hosted on Linux, within an Active Directory domain (managed by Samba 4 and hosted on a Raspberry Pi 4; yes, this is how I run my home lab; no, I am not interested in your judgment). The service was not strictly joined to the domain, but did have a krb5.conf file making it aware of the domain controller. While this experience (described in RFC4559, a Microsoft extension to RFC4178) feels magical from a Windows client to Windows server within the domain, it is markedly less so should one be foolish enough to take a single step outside.

Let us assume, for the sake of argument, that you have a similar problem. Let us also assume that your project is structured the following way:

$APP_ROOT
├── app
│   ├── __init__.py
│   ├── static
│   ├── templates
│   ├── views.py
│   └── wsgi.py
├── app.keytab
├── config.py
├── LICENSE
├── py_ad_principal.toml
├── README.md
└── requirements.txt

Because you are using the application factory pattern, your Flask app is initialized in $APP_ROOT/app/__init__.py. We will also be paying special attention to x other files:

  • requirements.txt where, in addition to Flask and this module, you've taken a dependency on Flask-Login
  • views.py, where you write code to respond to HTTP actions (here, we care about those involved in logn)
  • app.keytab, where you store pre-generated credentials for your Kerberos service principal. Microsoft provides instructions for creating keytab files on Windows server; the more adventurous will find Samba directions here.
  • py_ad_principal.toml, where we store the module's configuration values.

For this example, py_ad_principal.toml has the following contents:

[krb5]
service = "HTTP"
hostname = "dc.domain.local"
keytab = "app.keytab"

[ldap]
server = "dc.domain.local"
use_tls = true
search_base = "CN=Users,DC=domain,DC=local"
nested_groups = false
anonymous_bind = false
bind_user = "DOMAIN\\HTTP"
bind_password = "MySecretPassword"

For simplicity's sake, we're going to create a global variable named ad_auth_context to hold a shared authentication context. We do this in __init__.py when we create out Flask app.

# Declare any system imports
from py_ad_principal import AuthenticationContext, AuthenticationContextConfig
from flask_login import LoginManager
# Other 3rd party imports go here

# Flask app factory function; assume you've created a class named 'Config' to
# manage configuration values.
def create_app(config_class=Config):
    app = Flask(__name__)

    # Configure blueprints and extensions, and perform other setup tasks.

    # Read Python AD Principal configuration from the standard location and define
    # a global variable to store the shared authentication context.
    py_ad_config = AuthenticationContextConfig()
    global ad_auth_ctx = AuthenticationContext( py_ad_config )

    # Create a global LoginManager instance for use in our view methods
    global login_manager
    login_manager = LoginManager()
    login_manager.session_protection = "strong"
    login_manager.login_view = "login"
    login_manager.login_message_category = "info"    

    return app

In views.py, we use ad_auth_ctx to integrate our principal with a Flask-Login authentication handler. First, we define the mandatory User class to wrap the attributes provided by our AD principal.

from flask import Response, redirect
from flask_login import UserMixin, login_required, login_user, logout_user
from py_ad_principal import ActiveDirectoryPrincipal, AuthenticationContext

from app import ad_auth_ctx, login_manager


# Define Flask-Login compatible user class to hold the principal's key attributes.
class User(UserMixin):
    def __init__(self, principal: ActiveDirectoryPrincipal):
        self._id = principal.principal_name
        self._username = principal.sam_account_name
        self._display_name = principal.display_name

    def __repr__(self):
        return f"<User {self._id}>"

    def __str__(self):
        return self._id

    def get_id(self):
        return self._id

    @property
    def username(self):
        return self._username

    @property
    def display_name(self):
        return self._display_name
    
    def to_dict(self):
        return {
            "id": self._id,
            "username": self._username,
            "display_name": self._display_name,
        }

    @staticmethod
    def from_dict(user_dict):
        return User(
            user_dict["id"], 
            user_dict["username"], 
            user_dict["display_name"],
        )

Next, we add the mandatory user management functions to views.py.

@login_manager.user_loader
def user_loader(user_id):
    user_principal = session.get("user_principal")
    if user_principal:
        return User.from_dict(user_principal)

    return None


@login_manager.request_loader
def request_loader(request):
    # We do not support restoring the user's identity from a token passed in
    # the header of each request.
    return None

Finally, we implement a protected method and the requisite, SPNEGO-compatible login function.

@app.route("/")
@login_required
def home():
    # Assume we have a Jinja2 template named "home.html"
    return render_template("home.html")

@app.route("/login", methods=["GET"])
def login():
    auth_header = request.headers.get("Authorization")

    # Yes, I'm asking for permission rather than forgiveness.
    if auth_header and ad_auth_ctx.is_valid_token(auth_header):
        # Strip out the 'Negotiate ' preamble present in HTTP-based SPNEGO
        # implementations.
        auth_token = auth_header[len("Negotiate ") :]

        # We are not resolving groups or mapping roles in this example.
        result = auth_context.authenticate_principal(auth_token)
        if result and result.principal:
            user = User(result.principal)
            login_user(user)
            session["user_principal"] = user.to_dict()
            return redirect(url_for("home"))
        elif result.server_token:
            # This shouldn't happen when authenticating with a KRB5 TGT. It's
            # shown in the interest of completion only!
            http_headers = {"WWW-Authenticate": f"Negotiate {result.server_token}"}
            return Response("Unauthorized", 401, http_headers)

    return Response("Unauthorized", 401, {"WWW-Authenticate": "Negotiate"})

@app.route("/logout", methods=["GET"])
def logout():
    logout_user()
    return redirect(url_for("home"))

Now, whenever the user attempts to access our (utterly useless) website, they are routed to the login() method which commences GSSAPI protocol negotiation by responding with HTTP 401 (unauthorized) and the WWW-Authenticate response header having the value Negotiate.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

py_ad_principal-0.1.2.tar.gz (14.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

py_ad_principal-0.1.2-py3-none-any.whl (11.4 kB view details)

Uploaded Python 3

File details

Details for the file py_ad_principal-0.1.2.tar.gz.

File metadata

  • Download URL: py_ad_principal-0.1.2.tar.gz
  • Upload date:
  • Size: 14.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.1.1 CPython/3.12.3

File hashes

Hashes for py_ad_principal-0.1.2.tar.gz
Algorithm Hash digest
SHA256 3b5a8239c6498c4644d38847217ed2c84f4f3dce4daf4e2abaaa0610259beb27
MD5 7caca86f5302256a5733533220238ac7
BLAKE2b-256 03aa323b2e5863fc44ac373d40d6d5e071b3630a59c94af356e703912950e760

See more details on using hashes here.

File details

Details for the file py_ad_principal-0.1.2-py3-none-any.whl.

File metadata

File hashes

Hashes for py_ad_principal-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 2c661f42dae60ebe0427cc054f37092a14b61a245b2e5d610ab45c00d566af39
MD5 cdc41f11c8f48f53f37ff229b5dff115
BLAKE2b-256 c64316a88d5237668a249908b8a3b93961ed2c84660d391071a926acd049d273

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page