Adding LDAP support

This commit is contained in:
Ian Roddis committed 2026-07-19 12:02:41 -03:00
1 parent d60ad42f0c
commit f2b99089ea
17 files changed
+541 -23

No files matched your search

+8 -3
View File
@@ -10,7 +10,12 @@ from app.deps import get_current_user
from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
from app.services.auth import (
RefreshError,
authenticate,
issue_token_pair,
rotate_refresh_token,
)
router = APIRouter()
@@ -40,8 +45,8 @@ async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
# OAuth2 form uses ``username``; we treat it as the email.
user = await user_crud.authenticate(db, form.username, form.password)
# OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
user = await authenticate(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+34
View File
@@ -40,6 +40,40 @@ class Settings(BaseSettings):
# CORS
cors_origins: list[str] = ["*"]
# --- LDAP / Active Directory (optional) --------------------------------
# When ``ldap_enabled`` is true, login credentials are validated by binding
# to the directory. A local ``User`` row is provisioned on first successful
# login so RBAC, tokens, and the admin UI keep working. Local database auth
# remains available as a fallback (so the seeded superuser can still log in).
ldap_enabled: bool = False
# e.g. "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"
ldap_server_uri: str = ""
ldap_use_ssl: bool = False
ldap_start_tls: bool = False
# Verify the server's TLS certificate (ldaps/StartTLS). Disable only for dev.
ldap_tls_validate: bool = True
ldap_ca_certs_file: str = ""
# Two ways to locate/authenticate the user (direct bind takes precedence):
#
# 1. Direct bind — build the user's DN from the login name, then bind as it:
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
ldap_bind_dn_template: str = ""
#
# 2. Search + bind — bind with a service account (or anonymously), search for
# the user, then re-bind as the found DN to verify the password:
ldap_bind_dn: str = ""
ldap_bind_password: str = ""
ldap_user_search_base: str = ""
ldap_user_search_filter: str = "(uid={username})"
# Attributes read from the directory entry to populate the local user.
ldap_attr_email: str = "mail"
ldap_attr_full_name: str = "cn"
# Roles (by name) granted to a user provisioned from LDAP on first login.
ldap_default_roles: list[str] = ["user"]
# Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis"
+11
View File
@@ -1,6 +1,7 @@
"""Password hashing and JWT token helpers."""
import hashlib
import secrets
import uuid
from datetime import UTC, datetime, timedelta
@@ -27,6 +28,16 @@ def verify_password(password: str, hashed: str) -> bool:
return False
def make_unusable_password() -> str:
"""A sentinel hash that no password can match.
Used for externally-authenticated users (e.g. LDAP) who have no local
password. It is not a valid bcrypt hash, so ``verify_password`` always
returns ``False`` for it — local password auth can never succeed.
"""
return "!" + secrets.token_urlsafe(32)
# --- JWT ---------------------------------------------------------------------
+51 -1
View File
@@ -1,7 +1,7 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.core.security import hash_password, make_unusable_password, verify_password
from app.models.role import Role
from app.models.user import User
@@ -32,6 +32,13 @@ async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
return list(result.scalars().all())
async def _resolve_roles_by_name(db: AsyncSession, names: list[str]) -> list[Role]:
if not names:
return []
result = await db.execute(select(Role).where(Role.name.in_(names)))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
@@ -40,12 +47,14 @@ async def create(
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
auth_provider: str = "local",
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
full_name=full_name,
is_superuser=is_superuser,
auth_provider=auth_provider,
roles=await _resolve_roles(db, role_ids or []),
)
db.add(user)
@@ -54,6 +63,47 @@ async def create(
return user
async def upsert_ldap_user(
db: AsyncSession,
*,
email: str,
full_name: str | None = None,
default_role_names: list[str] | None = None,
) -> User:
"""Provision (or refresh) the local row for an LDAP-authenticated user.
On first login the user is created with an unusable local password (so
password auth can never succeed) and the configured default roles. On later
logins the existing row is reused; only ``full_name`` is synced from the
directory. Roles assigned afterwards in the admin UI are preserved.
"""
user = await get_by_email(db, email)
if user is None:
user = User(
email=email,
hashed_password=make_unusable_password(),
full_name=full_name,
auth_provider="ldap",
roles=await _resolve_roles_by_name(db, default_role_names or []),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
changed = False
if user.auth_provider != "ldap":
user.auth_provider = "ldap"
changed = True
if full_name and user.full_name != full_name:
user.full_name = full_name
changed = True
if changed:
await db.commit()
await db.refresh(user)
return user
async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password"))
+4
View File
@@ -16,6 +16,10 @@ class User(Base):
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
# Where this account authenticates: "local" (password) or "ldap".
auth_provider: Mapped[str] = mapped_column(
String(20), default="local", server_default="local"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+1
View File
@@ -31,6 +31,7 @@ class UserRead(UserBase):
id: int
is_active: bool
is_superuser: bool
auth_provider: str = "local"
created_at: datetime
roles: list[RoleRead] = []
+33 -1
View File
@@ -1,8 +1,12 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
"""Auth orchestration shared by the JSON API and the HTMX UI.
Covers credential verification (LDAP + local) and token issuance / rotation.
"""
import jwt
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import (
REFRESH_TOKEN_TYPE,
create_access_token,
@@ -10,7 +14,35 @@ from app.core.security import (
decode_token,
)
from app.crud import token as token_crud
from app.crud import user as user_crud
from app.models.user import User
from app.services import ldap as ldap_service
async def authenticate(db: AsyncSession, username: str, password: str) -> User | None:
"""Verify credentials and return the matching active user, or ``None``.
When LDAP is enabled the directory is tried first; on a successful bind the
local user row is provisioned/refreshed. Local database authentication is
always attempted as a fallback, so pre-existing accounts (e.g. the seeded
superuser) keep working alongside LDAP.
"""
if settings.ldap_enabled:
ldap_user = await ldap_service.authenticate_ldap(username, password)
if ldap_user is not None:
user = await user_crud.upsert_ldap_user(
db,
email=ldap_user.email,
full_name=ldap_user.full_name,
default_role_names=settings.ldap_default_roles,
)
return user if user.is_active else None
# Local password auth (the only path when LDAP is disabled).
user = await user_crud.authenticate(db, username, password)
if user is not None and not user.is_active:
return None
return user
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
+158
View File
@@ -0,0 +1,158 @@
"""LDAP / Active Directory authentication.
Enabled and configured via ``settings.ldap_*`` (see ``app/core/config.py``).
When enabled, ``authenticate_ldap`` validates a login by binding to the
directory. On success it returns the directory attributes needed to provision
or update a local ``User`` row; the local row is what RBAC, tokens, and the
admin UI operate on.
``ldap3`` is synchronous, so the blocking bind/search runs in a worker thread
via ``run_in_threadpool`` to avoid stalling the async event loop. ``ldap3`` is
imported lazily so the rest of the app runs even when it is not installed and
LDAP is disabled.
"""
from dataclasses import dataclass
from starlette.concurrency import run_in_threadpool
from app.core.config import settings
@dataclass(frozen=True)
class LdapUser:
"""Identity resolved from the directory for a successfully-bound user."""
email: str
full_name: str | None = None
async def authenticate_ldap(username: str, password: str) -> LdapUser | None:
"""Return the directory identity if ``username``/``password`` bind, else None.
Never raises for an ordinary auth failure (bad credentials, user not found,
server unreachable) — those all return ``None``.
"""
if not settings.ldap_enabled:
return None
if not username or not password:
# An empty password would trigger an anonymous bind that "succeeds"
# without proving anything — treat it as a failed login.
return None
return await run_in_threadpool(_authenticate_sync, username, password)
def _build_server():
from ldap3 import Server, Tls
tls = None
if settings.ldap_use_ssl or settings.ldap_start_tls:
import ssl
validate = ssl.CERT_REQUIRED if settings.ldap_tls_validate else ssl.CERT_NONE
tls = Tls(
validate=validate,
ca_certs_file=settings.ldap_ca_certs_file or None,
)
return Server(
settings.ldap_server_uri,
use_ssl=settings.ldap_use_ssl,
tls=tls,
get_info="NONE",
)
def _authenticate_sync(username: str, password: str) -> LdapUser | None:
"""Blocking bind + attribute lookup. Runs in a worker thread."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
server = _build_server()
try:
if settings.ldap_bind_dn_template:
# Direct bind: construct the DN and bind straight as the user.
user_dn = settings.ldap_bind_dn_template.format(
username=escape_rdn(username)
)
return _bind_and_read(server, user_dn, password, username)
# Search + bind: locate the entry first, then bind as its DN.
search_filter = settings.ldap_user_search_filter.format(
username=escape_filter_chars(username)
)
finder = Connection(
server,
user=settings.ldap_bind_dn or None,
password=settings.ldap_bind_password or None,
auto_bind=True,
)
try:
attrs = [settings.ldap_attr_email, settings.ldap_attr_full_name]
found = finder.search(
search_base=settings.ldap_user_search_base,
search_filter=search_filter,
attributes=attrs,
)
if not found or not finder.entries:
return None
entry = finder.entries[0]
user_dn = entry.entry_dn
finally:
finder.unbind()
return _bind_and_read(server, user_dn, password, username, entry=entry)
except LDAPException:
# Network error, invalid DN template, etc. Fail closed.
return None
except (KeyError, IndexError, ValueError):
# e.g. a bad ``{username}`` template placeholder.
return None
def _bind_and_read(server, user_dn, password, username, entry=None):
"""Bind as ``user_dn`` to verify the password, then extract attributes."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
try:
conn = Connection(server, user=user_dn, password=password, auto_bind=True)
except LDAPException:
# Wrong password / bind rejected.
return None
try:
# Direct-bind mode has no prior search entry; read the attributes now.
if entry is None:
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
search_scope="BASE",
attributes=[settings.ldap_attr_email, settings.ldap_attr_full_name],
)
entry = conn.entries[0] if conn.entries else None
finally:
conn.unbind()
email = _read_attr(entry, settings.ldap_attr_email) or _default_email(username)
full_name = _read_attr(entry, settings.ldap_attr_full_name)
return LdapUser(email=email.lower(), full_name=full_name)
def _read_attr(entry, name: str) -> str | None:
if entry is None:
return None
try:
value = entry[name].value
except (KeyError, LookupError):
return None
if isinstance(value, (list, tuple)):
value = value[0] if value else None
return str(value) if value else None
def _default_email(username: str) -> str:
"""Fallback identity when the directory entry exposes no email attribute."""
return username if "@" in username else f"{username}@ldap.local"
+6
View File
@@ -6,9 +6,15 @@
<h1>{{ app_name }}</h1>
<p class="muted">Sign in to continue</p>
{% if error %}<div class="alert">{{ error }}</div>{% endif %}
{% if ldap_enabled %}
<label>Email or username
<input type="text" name="email" value="{{ email or '' }}" required autofocus>
</label>
{% else %}
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>
{% endif %}
<label>Password
<input type="password" name="password" required>
</label>
+10 -5
View File
@@ -4,10 +4,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.user import User
from app.services.auth import issue_token_pair
from app.services.auth import authenticate, issue_token_pair
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
@@ -35,7 +34,9 @@ async def dashboard(
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {})
return templates.TemplateResponse(
request, "login.html", {"ldap_enabled": settings.ldap_enabled}
)
@router.post("/login")
@@ -45,13 +46,17 @@ async def login_submit(
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
user = await user_crud.authenticate(db, email, password)
user = await authenticate(db, email, password)
if user is None:
# Re-render the form with an error (HTMX swaps the form partial).
return templates.TemplateResponse(
request,
"login.html",
{"error": "Incorrect email or password", "email": email},
{
"error": "Incorrect email or password",
"email": email,
"ldap_enabled": settings.ldap_enabled,
},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access, _ = await issue_token_pair(db, user)