← All Modules

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 displayName
  • session['user_dn'] — full distinguishedName
  • session['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.