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
+529 -11

No files matched your search

+26
View File
@@ -29,6 +29,32 @@ COOKIE_SECURE=false
# --- CORS (JSON list) --- # --- CORS (JSON list) ---
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
# --- LDAP / Active Directory (optional) ---
# When enabled, login credentials are validated against the directory and a
# local user is provisioned on first login. Local DB auth still works as a
# fallback (so the seeded superuser can log in). Disabled by default.
LDAP_ENABLED=false
LDAP_SERVER_URI="ldap://ldap.example.com:389"
LDAP_USE_SSL=false
LDAP_START_TLS=false
LDAP_TLS_VALIDATE=true
# LDAP_CA_CERTS_FILE="/etc/ssl/certs/ca.pem"
# Option 1 — direct bind: build the user DN from the login name.
# LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com"
# Option 2 — search + bind: a service account finds the user, then we bind as
# the found DN. Leave the template above empty to use this mode.
# LDAP_BIND_DN="cn=svc-reader,dc=example,dc=com"
# LDAP_BIND_PASSWORD="service-account-password"
# LDAP_USER_SEARCH_BASE="ou=people,dc=example,dc=com"
# LDAP_USER_SEARCH_FILTER="(uid={username})"
# Directory attributes copied onto the local user, and roles granted on first login.
LDAP_ATTR_EMAIL=mail
LDAP_ATTR_FULL_NAME=cn
LDAP_DEFAULT_ROLES=["user"]
# --- Bootstrap superuser (used by scripts/seed.py) --- # --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@example.com FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethis FIRST_SUPERUSER_PASSWORD=changethis
+7
View File
@@ -44,6 +44,13 @@ token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`. failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
- **Credential verification** goes through `services/auth.authenticate(db, username,
password)` — both login routes call it, not `crud.user.authenticate` directly.
When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`,
sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local
row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`,
`LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in
`settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`.
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the - **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. `refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
+24 -1
View File
@@ -5,7 +5,7 @@ A production-shaped starting point for a web app that serves both **HTML (HTMX)*
## Stack ## Stack
| Concern | Choice | | Concern | Choice |
|----------------|--------| | -------------------- | ----------------------------------------------------------- |
| Web framework | FastAPI | | Web framework | FastAPI |
| HTML / interactivity | Jinja2 + HTMX | | HTML / interactivity | Jinja2 + HTMX |
| ORM | SQLAlchemy 2.0 (async) | | ORM | SQLAlchemy 2.0 (async) |
@@ -154,6 +154,7 @@ Every setting is read from an environment variable first, then `.env`
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod | | `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS | | `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed | | `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
| `LDAP_ENABLED` | `false` | validate logins against LDAP/AD (see below) |
## Auth model ## Auth model
@@ -168,6 +169,28 @@ stores the access token in an httpOnly cookie so pages work without client JS.
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked` Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
flag, so logout and rotation genuinely invalidate them. flag, so logout and rotation genuinely invalidate them.
### LDAP / Active Directory (optional)
Set `LDAP_ENABLED=true` to validate logins against a directory. On a successful
bind, a local user is **provisioned on first login** (with an unusable local
password and the roles in `LDAP_DEFAULT_ROLES`) so RBAC, tokens, and the admin
UI work unchanged. Existing accounts still authenticate against the local
database as a fallback, so the seeded superuser keeps working.
Two lookup modes (configured in `.env`, see `.env.example`):
- **Direct bind** — set `LDAP_BIND_DN_TEMPLATE`, e.g.
`uid={username},ou=people,dc=example,dc=com`; the login name is substituted
and used to bind directly.
- **Search + bind** — leave the template empty and set `LDAP_BIND_DN` /
`LDAP_BIND_PASSWORD` (a service account) plus `LDAP_USER_SEARCH_BASE` /
`LDAP_USER_SEARCH_FILTER`; the user is located, then re-bound to verify the
password.
The local user's email/name come from the `LDAP_ATTR_EMAIL` / `LDAP_ATTR_FULL_NAME`
attributes. Use `ldaps://` (`LDAP_USE_SSL=true`) or `LDAP_START_TLS=true` in
production so credentials aren't sent in cleartext.
## RBAC ## RBAC
`users → roles → permissions`. Permissions follow `resource:action` `users → roles → permissions`. Permissions follow `resource:action`
@@ -0,0 +1,33 @@
"""add auth_provider to users
Revision ID: 0002_add_auth_provider
Revises: 0001_initial
Create Date: 2026-07-19 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_add_auth_provider"
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"auth_provider",
sa.String(length=20),
nullable=False,
server_default="local",
),
)
def downgrade() -> None:
op.drop_column("users", "auth_provider")
+8 -3
View File
@@ -10,7 +10,12 @@ from app.deps import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead 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() router = APIRouter()
@@ -40,8 +45,8 @@ async def login(
form: OAuth2PasswordRequestForm = Depends(), form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# OAuth2 form uses ``username``; we treat it as the email. # OAuth2 form uses ``username``; we treat it as the email (or LDAP login).
user = await user_crud.authenticate(db, form.username, form.password) user = await authenticate(db, form.username, form.password)
if user is None: if user is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+34
View File
@@ -40,6 +40,40 @@ class Settings(BaseSettings):
# CORS # CORS
cors_origins: list[str] = ["*"] 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) # Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com" first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis" first_superuser_password: str = "changethis"
+11
View File
@@ -1,6 +1,7 @@
"""Password hashing and JWT token helpers.""" """Password hashing and JWT token helpers."""
import hashlib import hashlib
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -27,6 +28,16 @@ def verify_password(password: str, hashed: str) -> bool:
return False 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 --------------------------------------------------------------------- # --- JWT ---------------------------------------------------------------------
+51 -1
View File
@@ -1,7 +1,7 @@
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.role import Role
from app.models.user import User 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()) 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( async def create(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -40,12 +47,14 @@ async def create(
full_name: str | None = None, full_name: str | None = None,
is_superuser: bool = False, is_superuser: bool = False,
role_ids: list[int] | None = None, role_ids: list[int] | None = None,
auth_provider: str = "local",
) -> User: ) -> User:
user = User( user = User(
email=email, email=email,
hashed_password=hash_password(password), hashed_password=hash_password(password),
full_name=full_name, full_name=full_name,
is_superuser=is_superuser, is_superuser=is_superuser,
auth_provider=auth_provider,
roles=await _resolve_roles(db, role_ids or []), roles=await _resolve_roles(db, role_ids or []),
) )
db.add(user) db.add(user)
@@ -54,6 +63,47 @@ async def create(
return user 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: async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]: if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("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) full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
+1
View File
@@ -31,6 +31,7 @@ class UserRead(UserBase):
id: int id: int
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
auth_provider: str = "local"
created_at: datetime created_at: datetime
roles: list[RoleRead] = [] 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 import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import ( from app.core.security import (
REFRESH_TOKEN_TYPE, REFRESH_TOKEN_TYPE,
create_access_token, create_access_token,
@@ -10,7 +14,35 @@ from app.core.security import (
decode_token, decode_token,
) )
from app.crud import token as token_crud from app.crud import token as token_crud
from app.crud import user as user_crud
from app.models.user import User 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]: 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> <h1>{{ app_name }}</h1>
<p class="muted">Sign in to continue</p> <p class="muted">Sign in to continue</p>
{% if error %}<div class="alert">{{ error }}</div>{% endif %} {% 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 <label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus> <input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label> </label>
{% endif %}
<label>Password <label>Password
<input type="password" name="password" required> <input type="password" name="password" required>
</label> </label>
+10 -5
View File
@@ -4,10 +4,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db 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.deps import get_current_web_user
from app.models.user import 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 from app.web.templates import templates
router = APIRouter(include_in_schema=False) router = APIRouter(include_in_schema=False)
@@ -35,7 +34,9 @@ async def dashboard(
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): 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") @router.post("/login")
@@ -45,13 +46,17 @@ async def login_submit(
password: str = Form(...), password: str = Form(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
user = await user_crud.authenticate(db, email, password) user = await authenticate(db, email, password)
if user is None: if user is None:
# Re-render the form with an error (HTMX swaps the form partial). # Re-render the form with an error (HTMX swaps the form partial).
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"login.html", "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, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access, _ = await issue_token_pair(db, user) access, _ = await issue_token_pair(db, user)
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"pydantic-settings>=2.3", "pydantic-settings>=2.3",
"pyjwt>=2.8", "pyjwt>=2.8",
"bcrypt>=4.1", "bcrypt>=4.1",
"ldap3>=2.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+99
View File
@@ -0,0 +1,99 @@
"""LDAP authentication tests.
The blocking directory bind (``_authenticate_sync``) is replaced with a fake so
no real LDAP server is needed; everything above it — the enabled gate, the
threadpool hop, JIT provisioning, and local fallback — runs for real.
"""
import pytest
from app.core.config import settings
from app.crud import user as user_crud
from app.services import ldap as ldap_service
DIRECTORY = {
# login name -> (password, email, full_name)
"jdoe": ("ldap-pass", "jdoe@corp.example", "Jane Doe"),
}
def _fake_sync(username: str, password: str):
record = DIRECTORY.get(username)
if record is None or record[0] != password:
return None
_, email, full_name = record
return ldap_service.LdapUser(email=email.lower(), full_name=full_name)
@pytest.fixture
def ldap_enabled(monkeypatch):
monkeypatch.setattr(settings, "ldap_enabled", True)
monkeypatch.setattr(settings, "ldap_default_roles", ["user"])
monkeypatch.setattr(ldap_service, "_authenticate_sync", _fake_sync)
async def test_ldap_login_provisions_local_user(
client, seeded, db_sessionmaker, ldap_enabled
):
resp = await client.post(
"/api/auth/login",
data={"username": "jdoe", "password": "ldap-pass"},
)
assert resp.status_code == 200, resp.text
access = resp.json()["access_token"]
# A local row was created from the directory entry.
async with db_sessionmaker() as db:
user = await user_crud.get_by_email(db, "jdoe@corp.example")
assert user is not None
assert user.auth_provider == "ldap"
assert user.full_name == "Jane Doe"
assert "user" in {r.name for r in user.roles} # default role applied
# The issued token authenticates against the API.
me = await client.get(
"/api/users/me", headers={"Authorization": f"Bearer {access}"}
)
assert me.status_code == 200
assert me.json()["email"] == "jdoe@corp.example"
assert me.json()["auth_provider"] == "ldap"
async def test_ldap_wrong_password_rejected(client, seeded, ldap_enabled):
resp = await client.post(
"/api/auth/login",
data={"username": "jdoe", "password": "nope"},
)
assert resp.status_code == 401
async def test_ldap_user_cannot_use_local_password(
client, seeded, db_sessionmaker, ldap_enabled
):
# Provision via LDAP first.
await client.post(
"/api/auth/login", data={"username": "jdoe", "password": "ldap-pass"}
)
# The local password slot is unusable, so DB auth must fail for this user
# even though the account now exists.
async with db_sessionmaker() as db:
assert (
await user_crud.authenticate(db, "jdoe@corp.example", "ldap-pass") is None
)
async def test_local_user_still_logs_in_when_ldap_enabled(
client, seeded, make_user, ldap_enabled
):
# A local account that does NOT exist in the directory falls back to DB auth.
await make_user("local@example.com", password="secret123")
resp = await client.post(
"/api/auth/login",
data={"username": "local@example.com", "password": "secret123"},
)
assert resp.status_code == 200, resp.text
async def test_empty_password_is_rejected(ldap_enabled):
# Guards against an anonymous bind masquerading as a successful login.
assert await ldap_service.authenticate_ldap("jdoe", "") is None
Generated
+23
View File
@@ -290,6 +290,7 @@ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "ldap3" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
@@ -317,6 +318,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.111" }, { name = "fastapi", specifier = ">=0.111" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "ldap3", specifier = ">=2.9" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" }, { name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pyjwt", specifier = ">=2.8" }, { name = "pyjwt", specifier = ">=2.8" },
@@ -516,6 +518,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "ldap3"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@@ -647,6 +661,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"