Adding LDAP login

This commit is contained in:
Ian Roddis committed 2026-07-16 17:05:30 -03:00
1 parent ab39191af5
commit cda15df2ed
22 files changed
+582 -15

No files matched your search

+32 -3
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import token as token_crud
@@ -10,27 +11,34 @@ 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 import ldap as ldap_service
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user"
async def _default_role_ids(db: AsyncSession) -> list[int]:
role = await role_crud.get_default(db)
if role is None:
role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
return [role.id] if role else []
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
role_ids=role_ids,
role_ids=await _default_role_ids(db),
)
return user
@@ -52,6 +60,27 @@ async def login(
return Token(access_token=access, refresh_token=refresh)
@router.post("/ldap-login", response_model=Token)
async def ldap_login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
"""Authenticate against LDAP, provisioning the local profile on first login."""
if not settings.ldap_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="LDAP login is not enabled"
)
user = await ldap_service.login(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try:
+13
View File
@@ -37,6 +37,19 @@ class Settings(BaseSettings):
cookie_name: str = "access_token"
cookie_secure: bool = False
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
# ``{username}`` substituted) and bound directly with their password.
ldap_enabled: bool = False
ldap_host: str = "localhost"
ldap_port: int = 389
ldap_use_ssl: bool = False
ldap_bind_dn_template: str = "uid={username},ou=people,dc=example,dc=com"
# Provisioned LDAP users get a synthesized email: "<username>@<domain>".
ldap_email_domain: str = "example.com"
# LDAP attribute read for the provisioned user's display name.
ldap_full_name_attr: str = "cn"
# CORS
cors_origins: list[str] = ["*"]
+28
View File
@@ -1,4 +1,5 @@
from sqlalchemy import select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission
@@ -14,6 +15,23 @@ async def get_by_name(db: AsyncSession, name: str) -> Role | None:
return result.scalar_one_or_none()
async def get_default(db: AsyncSession) -> Role | None:
"""The role auto-assigned to new users, or ``None`` if none is marked."""
result = await db.execute(select(Role).where(Role.is_default.is_(True)))
return result.scalars().first()
async def set_default(db: AsyncSession, role: Role) -> Role:
"""Make ``role`` the sole default, clearing the flag on every other role."""
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
await db.commit()
await db.refresh(role)
return role
async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all())
@@ -50,6 +68,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else:
data.pop("permission_ids", None)
# Setting a role default must clear the flag on all others.
make_default = data.pop("is_default", None)
if make_default:
await db.execute(
sa_update(Role).where(Role.id != role.id).values(is_default=False)
)
role.is_default = True
elif make_default is False:
role.is_default = False
for field, value in data.items():
if value is not None:
setattr(role, field, value)
+47 -4
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.role import Role
from app.models.user import User
from app.models.user import AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User
async def get(db: AsyncSession, user_id: int) -> User | None:
@@ -15,6 +15,11 @@ async def get_by_email(db: AsyncSession, email: str) -> User | None:
return result.scalar_one_or_none()
async def get_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all())
@@ -36,14 +41,18 @@ async def create(
db: AsyncSession,
*,
email: str,
password: str,
password: str | None = None,
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
username: str | None = None,
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
username=username,
hashed_password=hash_password(password) if password else None,
auth_source=auth_source,
full_name=full_name,
is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []),
@@ -54,6 +63,37 @@ async def create(
return user
async def get_or_create_ldap_user(
db: AsyncSession,
*,
username: str,
email: str,
full_name: str | None,
role_ids: list[int] | None = None,
) -> User:
"""Find an LDAP user by username, provisioning a local profile on first login.
The profile is keyed on the LDAP username; email is synthesized by the
caller. Newly provisioned users get ``role_ids`` (the default role).
"""
user = await get_by_username(db, username)
if user is not None:
# Keep the display name in sync with LDAP on subsequent logins.
if full_name and user.full_name != full_name:
user.full_name = full_name
await db.commit()
await db.refresh(user)
return user
return await create(
db,
email=email,
username=username,
full_name=full_name,
auth_source=AUTH_SOURCE_LDAP,
role_ids=role_ids,
)
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"))
@@ -81,6 +121,9 @@ async def delete(db: AsyncSession, user: User) -> None:
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if not user or not verify_password(password, user.hashed_password):
# LDAP users have no local password and must sign in via LDAP.
if not user or not user.hashed_password:
return None
if not verify_password(password, user.hashed_password):
return None
return user
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -11,6 +11,9 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
# The role auto-assigned to newly provisioned/registered users. At most one
# role is the default; see ``crud.role.set_default``.
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles,
+11 -1
View File
@@ -6,13 +6,23 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import user_roles
# Where a user's credentials are verified.
AUTH_SOURCE_LOCAL = "local"
AUTH_SOURCE_LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
# Set for users provisioned from LDAP; how they sign in. Null for local users.
username: Mapped[str | None] = mapped_column(
String(255), unique=True, index=True, default=None
)
# Null for LDAP users, who have no local password (verified against LDAP).
hashed_password: Mapped[str | None] = mapped_column(String(255), default=None)
auth_source: Mapped[str] = mapped_column(String(16), default=AUTH_SOURCE_LOCAL)
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)
+2
View File
@@ -12,6 +12,7 @@ class PermissionRead(BaseModel):
class RoleBase(BaseModel):
name: str
description: str | None = None
is_default: bool = False
class RoleCreate(RoleBase):
@@ -21,6 +22,7 @@ class RoleCreate(RoleBase):
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_default: bool | None = None
permission_ids: list[int] | None = None
+2
View File
@@ -29,6 +29,8 @@ class UserRead(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
username: str | None = None
auth_source: str = "local"
is_active: bool
is_superuser: bool
created_at: datetime
+98
View File
@@ -0,0 +1,98 @@
"""LDAP authentication (direct-bind) and just-in-time user provisioning.
``ldap3`` is synchronous, so its network calls run in a threadpool to avoid
blocking the event loop. Login builds the user's DN from
``settings.ldap_bind_dn_template`` and binds directly with their password; a
successful profile is provisioned locally on first sign-in.
"""
from dataclasses import dataclass
from fastapi.concurrency import run_in_threadpool
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.crud import role as role_crud
from app.crud import user as user_crud
from app.models.user import User
@dataclass
class LdapIdentity:
username: str
email: str
full_name: str | None
def _bind_and_fetch(username: str, password: str) -> LdapIdentity | None:
"""Bind as the user and read their display name. Returns ``None`` on failure.
Runs synchronously inside a threadpool.
"""
import ldap3
from ldap3.core.exceptions import LDAPException
user_dn = settings.ldap_bind_dn_template.format(username=username)
server = ldap3.Server(
settings.ldap_host,
port=settings.ldap_port,
use_ssl=settings.ldap_use_ssl,
get_info=ldap3.NONE,
)
try:
conn = ldap3.Connection(
server, user=user_dn, password=password, auto_bind=True
)
except LDAPException:
# Wrong credentials, unreachable server, or a refused bind.
return None
full_name: str | None = None
attr = settings.ldap_full_name_attr
try:
found = conn.search(
user_dn,
"(objectClass=*)",
search_scope=ldap3.BASE,
attributes=[attr],
)
if found and conn.entries:
values = conn.entries[0].entry_attributes_as_dict.get(attr) or []
full_name = str(values[0]) if values else None
except LDAPException:
full_name = None
finally:
conn.unbind()
email = f"{username}@{settings.ldap_email_domain}"
return LdapIdentity(username=username, email=email, full_name=full_name)
async def authenticate(username: str, password: str) -> LdapIdentity | None:
"""Verify credentials against LDAP. Returns the identity or ``None``."""
if not settings.ldap_enabled:
return None
# A simple bind with an empty password can succeed as an anonymous bind;
# reject empties so that never counts as authentication.
if not username or not password:
return None
return await run_in_threadpool(_bind_and_fetch, username, password)
async def login(db: AsyncSession, username: str, password: str) -> User | None:
"""Authenticate against LDAP and provision/return the local user profile."""
identity = await authenticate(username, password)
if identity is None:
return None
default_role = await role_crud.get_default(db)
role_ids = [default_role.id] if default_role else []
user = await user_crud.get_or_create_ldap_user(
db,
username=identity.username,
email=identity.email,
full_name=identity.full_name,
role_ids=role_ids,
)
if not user.is_active:
return None
return user
+10
View File
@@ -28,6 +28,16 @@
</tbody>
</table>
</td>
<td>
{% if r.is_default %}
<span class="tag">default</span>
{% else %}
<button class="btn btn-ghost"
hx-post="/admin/roles/{{ r.id }}/default"
hx-target="#role-list"
hx-swap="outerHTML">Make default</button>
{% endif %}
</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/edit"
+1 -1
View File
@@ -1,5 +1,5 @@
<tr id="role-{{ r.id }}">
<td colspan="5">
<td colspan="6">
<form hx-post="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
+1 -1
View File
@@ -1,7 +1,7 @@
<div id="role-list" class="card">
<table class="table">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Permissions</th><th></th></tr>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Permissions</th><th>Default</th><th></th></tr>
</thead>
<tbody>
{% for r in roles %}
+14
View File
@@ -14,5 +14,19 @@
</label>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
{% if ldap_enabled %}
<form class="card auth-card" method="post" action="/login/ldap">
<h2>Sign in with LDAP</h2>
<p class="muted">Use your directory username and password</p>
<label>Username
<input type="text" name="username" value="{{ username or '' }}" required>
</label>
<label>Password
<input type="password" name="password" required>
</label>
<button type="submit" class="btn btn-primary">Sign in with LDAP</button>
</form>
{% endif %}
</div>
{% endblock %}
+27
View File
@@ -360,6 +360,33 @@ async def update_role(
)
@router.post("/roles/{role_id}/default", response_class=HTMLResponse)
async def make_default_role(
request: Request,
role_id: int,
db: AsyncSession = Depends(get_db),
actor: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=404, detail="Role not found")
await role_crud.set_default(db, role)
await audit_crud.log(
db,
actor=actor,
action="update",
resource_type="Role",
resource_id=role_id,
description=f"Set role {role.name} (#{role_id}) as the default role",
)
# Re-render the whole table so every row's default badge refreshes.
return templates.TemplateResponse(
request,
"admin/_roles_table.html",
{"roles": await role_crud.list_roles(db), **(await _role_perm_ctx(db))},
)
@router.delete("/roles/{role_id}")
async def delete_role(
role_id: int,
+37 -3
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,7 @@ 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 import ldap as ldap_service
from app.services.auth import issue_token_pair
from app.web.templates import templates
@@ -35,7 +36,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")
@@ -51,7 +54,38 @@ async def login_submit(
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)
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
_set_auth_cookie(response, access)
return response
@router.post("/login/ldap")
async def login_ldap_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
if not settings.ldap_enabled:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
user = await ldap_service.login(db, username, password)
if user is None:
return templates.TemplateResponse(
request,
"login.html",
{
"error": "LDAP sign-in failed",
"username": username,
"ldap_enabled": True,
},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access, _ = await issue_token_pair(db, user)