Adding LDAP support
This commit is contained in:
1 parent
d60ad42f0c
commit
f2b99089ea
17 files changed
+541
-23
No files matched your search
+8
-3
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user