ldap_auth
Replaces the default username/password login with Active Directory domain credential authentication. Uses a two-bind strategy: a service account locates the user DN and checks group membership, then the user's own credentials are verified with a second bind.
Rename
/ldap_login and /ldap_logout in the route snippet
to /login and /logout to replace the scaffold defaults.
Dependencies
pip install ldap3
Files
Copy each file into your project at the destination path shown. Click a tab to view the file, then use the Copy button.
→ lib/ldap_auth/auth.py
import os
from ldap3 import Server, Connection, ALL, SUBTREE
from ldap3.utils.conv import escape_filter_chars
def _load_env():
"""Load .env from the project root if AD_SERVER isn't already set."""
if os.environ.get('AD_SERVER'):
return
env_path = os.path.join(os.path.dirname(__file__), '..', '..', '.env')
if not os.path.isfile(env_path):
return
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, _, v = line.partition('=')
k, v = k.strip(), v.strip()
if '#' in v:
v = v[:v.index('#')].strip()
if len(v) >= 2 and v[0] in ('"', "'") and v[-1] == v[0]:
v = v[1:-1]
os.environ.setdefault(k, v)
_load_env()
def ldap_authenticate(username, password):
"""
Authenticate a user against Active Directory using a two-bind strategy:
1. Service account bind — locate the user's DN, check the account is not
in a Disabled OU, and optionally verify group membership.
2. User credential bind — verify the supplied password is correct.
Required env vars (set in .env):
AD_SERVER LDAP URL, e.g. ldap://dc.domain.com
AD_BASE_DN Search base, e.g. DC=domain,DC=com
AD_USER Service account UPN or DN
AD_PASSWORD Service account password
AD_REQUIRED_GROUP CN of group required to log in (leave blank for any domain user)
Returns on success:
{
'username': sAMAccountName,
'display_name': displayName (falls back to username),
'dn': full distinguishedName,
'groups': list of CN strings the user is a member of,
}
Returns None on any failure (wrong credentials, disabled account,
not in required group, LDAP unreachable, etc.).
"""
AD_SERVER = os.environ.get('AD_SERVER', '')
AD_BASE_DN = os.environ.get('AD_BASE_DN', '')
AD_USER = os.environ.get('AD_USER', '')
AD_PASSWORD = os.environ.get('AD_PASSWORD', '')
AD_REQUIRED_GROUP = os.environ.get('AD_REQUIRED_GROUP', '')
if not AD_SERVER:
return None
def _in_disabled_ou(dn):
return dn and any(p.strip().lower() == 'ou=disabled' for p in dn.split(','))
# --- Step 1: service-account bind to find user DN and check group ---
try:
conn = Connection(Server(AD_SERVER, get_info=ALL),
user=AD_USER, password=AD_PASSWORD, auto_bind=True)
except Exception as e:
print("LDAP connect error:", e)
return None
user_dn = None
display_name = username
groups = []
try:
safe = escape_filter_chars(username)
conn.search(
search_base=AD_BASE_DN,
search_filter=f'(&(objectClass=user)(sAMAccountName={safe}))',
search_scope=SUBTREE,
attributes=['distinguishedName', 'displayName', 'memberOf', 'userAccountControl']
)
if not conn.entries:
return None
entry = conn.entries[0]
user_dn = entry.entry_dn
if _in_disabled_ou(user_dn):
return None
raw = str(entry['displayName'].value or '')
display_name = raw if raw else username
member_of = [str(g) for g in (entry['memberOf'].values or [])]
for g in member_of:
for part in g.split(','):
if part.strip().lower().startswith('cn='):
groups.append(part.strip()[3:])
break
if AD_REQUIRED_GROUP:
needle = f'cn={AD_REQUIRED_GROUP.lower()},'
if not any(needle in g.lower() for g in member_of):
return None
except Exception as e:
print("LDAP search error:", e)
return None
finally:
conn.unbind()
if not user_dn:
return None
# --- Step 2: bind as the user to verify password ---
try:
user_conn = Connection(Server(AD_SERVER, get_info=ALL),
user=user_dn, password=password, auto_bind=True)
user_conn.unbind()
except Exception:
return None
return {
'username': username,
'display_name': display_name,
'dn': user_dn,
'groups': groups,
}
→ paste into app.stpl
{#
================================================================
LDAP AUTH ROUTES (prefab module: ldap_auth)
Drop these into your app.stpl, replacing the default /login route.
NOTE: routes are named /ldap_login and /ldap_logout here to avoid
conflicting with the scaffold routes in the prefabs demo app.
Rename them to /login and /logout in your own project.
================================================================
#}
@route('/ldap_login', methods=['GET', 'POST'])
{$
page_title = "Login"
error = None
if 'user_id' in session:
return redirect('/')
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
user = ldap_authenticate(username, password)
if user:
session['user_id'] = user['username']
session['display_name'] = user['display_name']
session['user_dn'] = user['dn']
session['user_groups'] = user['groups']
session.permanent = True
return redirect('/')
else:
error = "Invalid credentials or access denied"
$}
<div class="container">
<div class="card">
<h2>Login</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="POST">
{{ csrf() }}
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
value="{{ request.form.get('username', '') }}"
required
autofocus
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
>
</div>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
<p class="help-text text-center">Sign in with your Windows domain credentials.</p>
</div>
</div>
@route('/ldap_logout')
{$
session.clear()
flash('You have been logged out.', 'info')
return redirect('/ldap_login')
$}
→ add vars to .env
# Active Directory / LDAP — required by the ldap_auth module
AD_SERVER=ldap://dc.domain.com # or ldaps:// for TLS
AD_BASE_DN=DC=domain,DC=com
[email protected] # service account UPN or DN
AD_PASSWORD=service-account-password
# Optional: CN of an AD group users must belong to (leave blank = any domain user)
AD_REQUIRED_GROUP=
Configuration
Add these variables to your project's .env file:
AD_SERVER=ldap://dc.domain.com
AD_BASE_DN=DC=domain,DC=com
[email protected]
AD_PASSWORD=your-service-account-password
AD_REQUIRED_GROUP= # optional: CN of required group
Leave AD_REQUIRED_GROUP blank to allow any enabled domain user to log in.
Session keys set on login
session['user_id']— sAMAccountName (login username)session['display_name']— AD displayNamesession['user_dn']— full distinguishedNamesession['user_groups']— list of group CNs
Using session data
@route('/dashboard')
@require_auth
{$
display_name = session['display_name']
is_admin = 'IT Admins' in session.get('user_groups', [])
$}
To combine with admin_settings: map AD group membership to roles on login. The has_role() helper works identically either way.