From cda15df2ed958fa55ea80d7be24ea519ed0572d3 Mon Sep 17 00:00:00 2001 From: Ian Roddis <31021769+iroddis@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:05:30 -0300 Subject: [PATCH] Adding LDAP login --- .claude/settings.local.json | 5 +- .env.example | 15 ++ .../c9f1a2b3d4e5_add_ldap_and_default_role.py | 54 +++++++ app/api/auth.py | 35 ++++- app/core/config.py | 13 ++ app/crud/role.py | 28 ++++ app/crud/user.py | 51 +++++- app/models/role.py | 5 +- app/models/user.py | 12 +- app/schemas/rbac.py | 2 + app/schemas/user.py | 2 + app/services/ldap.py | 98 ++++++++++++ app/templates/admin/_role_row.html | 10 ++ app/templates/admin/_role_row_edit.html | 2 +- app/templates/admin/_roles_table.html | 2 +- app/templates/login.html | 14 ++ app/web/admin.py | 27 ++++ app/web/pages.py | 40 ++++- pyproject.toml | 1 + scripts/seed.py | 10 ++ tests/test_ldap_login.py | 148 ++++++++++++++++++ uv.lock | 23 +++ 22 files changed, 582 insertions(+), 15 deletions(-) create mode 100644 alembic/versions/c9f1a2b3d4e5_add_ldap_and_default_role.py create mode 100644 app/services/ldap.py create mode 100644 tests/test_ldap_login.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3b86e28..c930de3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(git stash *)", "Bash(python3 *)", "Bash(curl -s -X POST http://localhost:8000/api/auth/login -d \"username=admin@armament.com&password=changethis\" -H \"Content-Type: application/x-www-form-urlencoded\")", - "Bash(PYTHONPATH=. uv run *)" + "Bash(PYTHONPATH=. uv run *)", + "Bash(python *)", + "Bash(DATABASE_URL=\"sqlite+aiosqlite:////tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db\" uv run alembic upgrade head)", + "Bash(sqlite3 /tmp/claude-1000/-home-iroddis-dev-armament-fitguide/41e5e14d-88fe-45e7-ba3a-be5a2cbde2cd/scratchpad/mig_test.db '.schema users')" ] } } diff --git a/.env.example b/.env.example index 253983b..c161e92 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,21 @@ COOKIE_NAME=access_token # Set true when serving over HTTPS. COOKIE_SECURE=false +# --- LDAP (optional; direct-bind) --- +# When enabled, the login page and /api/auth/ldap-login accept a directory +# username + password. On first login a local profile is provisioned using the +# username and the default role (mark one in the Roles admin UI). +LDAP_ENABLED=false +LDAP_HOST=localhost +LDAP_PORT=389 +LDAP_USE_SSL=false +# "{username}" is substituted with the submitted username to form the bind DN. +LDAP_BIND_DN_TEMPLATE="uid={username},ou=people,dc=example,dc=com" +# Provisioned users get the email "@". +LDAP_EMAIL_DOMAIN=example.com +# LDAP attribute read for the provisioned user's display name. +LDAP_FULL_NAME_ATTR=cn + # --- CORS (JSON list) --- CORS_ORIGINS=["*"] diff --git a/alembic/versions/c9f1a2b3d4e5_add_ldap_and_default_role.py b/alembic/versions/c9f1a2b3d4e5_add_ldap_and_default_role.py new file mode 100644 index 0000000..fffe7d6 --- /dev/null +++ b/alembic/versions/c9f1a2b3d4e5_add_ldap_and_default_role.py @@ -0,0 +1,54 @@ +"""add LDAP user fields and default role flag + +Revision ID: c9f1a2b3d4e5 +Revises: 1b99be8a9b1e +Create Date: 2026-07-16 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "c9f1a2b3d4e5" +down_revision: str | None = "1b99be8a9b1e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Batch mode so SQLite can alter the nullability of hashed_password. + with op.batch_alter_table("users") as batch: + batch.add_column(sa.Column("username", sa.String(length=255), nullable=True)) + batch.add_column( + sa.Column( + "auth_source", + sa.String(length=16), + nullable=False, + server_default="local", + ) + ) + batch.alter_column( + "hashed_password", existing_type=sa.String(length=255), nullable=True + ) + batch.create_index("ix_users_username", ["username"], unique=True) + + op.add_column( + "roles", + sa.Column( + "is_default", sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + + +def downgrade() -> None: + op.drop_column("roles", "is_default") + + with op.batch_alter_table("users") as batch: + batch.drop_index("ix_users_username") + batch.alter_column( + "hashed_password", existing_type=sa.String(length=255), nullable=False + ) + batch.drop_column("auth_source") + batch.drop_column("username") diff --git a/app/api/auth.py b/app/api/auth.py index bda52bf..1fe5aaf 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -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: diff --git a/app/core/config.py b/app/core/config.py index edb9c95..0d928a2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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: "@". + 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] = ["*"] diff --git a/app/crud/role.py b/app/crud/role.py index 0def107..e07fc51 100644 --- a/app/crud/role.py +++ b/app/crud/role.py @@ -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) diff --git a/app/crud/user.py b/app/crud/user.py index d371f0a..0cc02e2 100644 --- a/app/crud/user.py +++ b/app/crud/user.py @@ -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 diff --git a/app/models/role.py b/app/models/role.py index a719548..345816d 100644 --- a/app/models/role.py +++ b/app/models/role.py @@ -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, diff --git a/app/models/user.py b/app/models/user.py index 7a30ff6..3c64529 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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) diff --git a/app/schemas/rbac.py b/app/schemas/rbac.py index 1e5488f..85ecc9a 100644 --- a/app/schemas/rbac.py +++ b/app/schemas/rbac.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py index 324a723..cf56169 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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 diff --git a/app/services/ldap.py b/app/services/ldap.py new file mode 100644 index 0000000..77e5c85 --- /dev/null +++ b/app/services/ldap.py @@ -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 diff --git a/app/templates/admin/_role_row.html b/app/templates/admin/_role_row.html index 7d0f37a..0e11164 100644 --- a/app/templates/admin/_role_row.html +++ b/app/templates/admin/_role_row.html @@ -28,6 +28,16 @@ + + {% if r.is_default %} + default + {% else %} + + {% endif %} +