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.txtwhere, in addition to Flask and this module, you've taken a dependency onFlask-Loginviews.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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file py_ad_principal-0.2.0.tar.gz.
File metadata
- Download URL: py_ad_principal-0.2.0.tar.gz
- Upload date:
- Size: 14.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.1.1 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eac8503d5cbd0a0cec7a3975b98dc6f29fbd5cedf6e1c20043118e71a221503d
|
|
| MD5 |
0cd0d87e8dd8164b64e424b2f60513f8
|
|
| BLAKE2b-256 |
a3b0b15ec03d9c1e2a9a8f6448b7402edac09c7eaf6f991a3765aff860e84cde
|
File details
Details for the file py_ad_principal-0.2.0-py3-none-any.whl.
File metadata
- Download URL: py_ad_principal-0.2.0-py3-none-any.whl
- Upload date:
- Size: 11.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.1.1 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
178bcd59ade745c43e55f627ac7d1fd761607a96de3631a1fc45bfbd47f4e10a
|
|
| MD5 |
6f9153230dc5d58c386dc8342a97c84f
|
|
| BLAKE2b-256 |
baa8ba76e72bc46a6b6dbd17a4407d06fd3f84e52db6677170d37ef478afee67
|