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

+4 -1
View File
@@ -6,7 +6,10 @@
"Bash(git stash *)", "Bash(git stash *)",
"Bash(python3 *)", "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(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')"
] ]
} }
} }
+15
View File
@@ -26,6 +26,21 @@ COOKIE_NAME=access_token
# Set true when serving over HTTPS. # Set true when serving over HTTPS.
COOKIE_SECURE=false 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 "<username>@<LDAP_EMAIL_DOMAIN>".
LDAP_EMAIL_DOMAIN=example.com
# LDAP attribute read for the provisioned user's display name.
LDAP_FULL_NAME_ATTR=cn
# --- CORS (JSON list) --- # --- CORS (JSON list) ---
CORS_ORIGINS=["*"] CORS_ORIGINS=["*"]
@@ -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")
+32 -3
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.crud import role as role_crud from app.crud import role as role_crud
from app.crud import token as token_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.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 import ldap as ldap_service
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter() router = APIRouter()
# Fallback default role name if no role is flagged as default in the DB.
DEFAULT_USER_ROLE = "user" 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) @router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email): if await user_crud.get_by_email(db, payload.email):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered" 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( user = await user_crud.create(
db, db,
email=payload.email, email=payload.email,
password=payload.password, password=payload.password,
full_name=payload.full_name, full_name=payload.full_name,
role_ids=role_ids, role_ids=await _default_role_ids(db),
) )
return user return user
@@ -52,6 +60,27 @@ async def login(
return Token(access_token=access, refresh_token=refresh) 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) @router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)): async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try: try:
+13
View File
@@ -37,6 +37,19 @@ class Settings(BaseSettings):
cookie_name: str = "access_token" cookie_name: str = "access_token"
cookie_secure: bool = False 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
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
+28
View File
@@ -1,4 +1,5 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission 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() 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]: async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id)) result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -50,6 +68,16 @@ async def update(db: AsyncSession, role: Role, data: dict) -> Role:
else: else:
data.pop("permission_ids", None) 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(): for field, value in data.items():
if value is not None: if value is not None:
setattr(role, field, value) 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.core.security import hash_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 AUTH_SOURCE_LDAP, AUTH_SOURCE_LOCAL, User
async def get(db: AsyncSession, user_id: int) -> User | None: 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() 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]: 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)) result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all()) return list(result.scalars().all())
@@ -36,14 +41,18 @@ async def create(
db: AsyncSession, db: AsyncSession,
*, *,
email: str, email: str,
password: str, password: str | None = None,
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,
username: str | None = None,
auth_source: str = AUTH_SOURCE_LOCAL,
) -> User: ) -> User:
user = User( user = User(
email=email, 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, full_name=full_name,
is_superuser=is_superuser, is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []), roles=await _resolve_roles(db, role_ids or []),
@@ -54,6 +63,37 @@ async def create(
return user 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: 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"))
@@ -81,6 +121,9 @@ async def delete(db: AsyncSession, user: User) -> None:
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None: async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email) 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 None
return user 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -11,6 +11,9 @@ class Role(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None) 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 users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles, 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.core.database import Base
from app.models.associations import user_roles 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=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) 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)
+2
View File
@@ -12,6 +12,7 @@ class PermissionRead(BaseModel):
class RoleBase(BaseModel): class RoleBase(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
is_default: bool = False
class RoleCreate(RoleBase): class RoleCreate(RoleBase):
@@ -21,6 +22,7 @@ class RoleCreate(RoleBase):
class RoleUpdate(BaseModel): class RoleUpdate(BaseModel):
name: str | None = None name: str | None = None
description: str | None = None description: str | None = None
is_default: bool | None = None
permission_ids: list[int] | None = None permission_ids: list[int] | None = None
+2
View File
@@ -29,6 +29,8 @@ class UserRead(UserBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
username: str | None = None
auth_source: str = "local"
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
created_at: datetime 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> </tbody>
</table> </table>
</td> </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"> <td class="row-actions">
<button class="btn btn-ghost" <button class="btn btn-ghost"
hx-get="/admin/roles/{{ r.id }}/edit" hx-get="/admin/roles/{{ r.id }}/edit"
+1 -1
View File
@@ -1,5 +1,5 @@
<tr id="role-{{ r.id }}"> <tr id="role-{{ r.id }}">
<td colspan="5"> <td colspan="6">
<form hx-post="/admin/roles/{{ r.id }}" <form hx-post="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}" hx-target="#role-{{ r.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
+1 -1
View File
@@ -1,7 +1,7 @@
<div id="role-list" class="card"> <div id="role-list" class="card">
<table class="table"> <table class="table">
<thead> <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> </thead>
<tbody> <tbody>
{% for r in roles %} {% for r in roles %}
+14
View File
@@ -14,5 +14,19 @@
</label> </label>
<button type="submit" class="btn btn-primary">Sign in</button> <button type="submit" class="btn btn-primary">Sign in</button>
</form> </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> </div>
{% endblock %} {% 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}") @router.delete("/roles/{role_id}")
async def delete_role( async def delete_role(
role_id: int, 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 fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession 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.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 import ldap as ldap_service
from app.services.auth import issue_token_pair from app.services.auth import issue_token_pair
from app.web.templates import templates from app.web.templates import templates
@@ -35,7 +36,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")
@@ -51,7 +54,38 @@ async def login_submit(
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,
)
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, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access, _ = await issue_token_pair(db, user) access, _ = await issue_token_pair(db, user)
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"pyjwt>=2.8", "pyjwt>=2.8",
"bcrypt>=4.1", "bcrypt>=4.1",
"httpx>=0.27", "httpx>=0.27",
"ldap3>=2.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+10
View File
@@ -77,6 +77,10 @@ PERMISSIONS = [
("audit_log:read", "View audit log"), ("audit_log:read", "View audit log"),
] ]
# Role auto-assigned to new (registered/LDAP-provisioned) users unless an admin
# marks a different one as default in the UI.
DEFAULT_ROLE = "user"
# Default roles -> permission names. "admin" gets everything. # Default roles -> permission names. "admin" gets everything.
ROLES = { ROLES = {
"admin": [name for name, _ in PERMISSIONS], # includes audit_log:read "admin": [name for name, _ in PERMISSIONS], # includes audit_log:read
@@ -127,6 +131,12 @@ async def seed() -> None:
) )
roles[role_name] = role roles[role_name] = role
# Ensure a default role is marked (idempotent).
if await role_crud.get_default(db) is None:
default_role = roles.get(DEFAULT_ROLE)
if default_role is not None:
await role_crud.set_default(db, default_role)
# Attachment point names # Attachment point names
for apn_name in ATTACHMENT_POINT_NAMES: for apn_name in ATTACHMENT_POINT_NAMES:
if await apn_crud.get_by_name(db, apn_name) is None: if await apn_crud.get_by_name(db, apn_name) is None:
+148
View File
@@ -0,0 +1,148 @@
import pytest
from app.core.config import settings
from app.crud import role as role_crud
from app.crud import user as user_crud
from app.services import ldap as ldap_service
@pytest.fixture
def ldap_enabled(monkeypatch):
monkeypatch.setattr(settings, "ldap_enabled", True)
def _fake_identity(username="jdoe", full_name="Jane Doe"):
return ldap_service.LdapIdentity(
username=username,
email=f"{username}@example.com",
full_name=full_name,
)
async def test_ldap_login_provisions_user_with_default_role(
client, seeded, db_sessionmaker, monkeypatch, ldap_enabled
):
# An admin has marked the "user" role as default.
async with db_sessionmaker() as db:
role = await role_crud.get(db, seeded["roles"]["user"].id)
await role_crud.set_default(db, role)
async def fake_authenticate(username, password):
assert (username, password) == ("jdoe", "secret")
return _fake_identity()
monkeypatch.setattr(ldap_service, "authenticate", fake_authenticate)
resp = await client.post(
"/api/auth/ldap-login",
data={"username": "jdoe", "password": "secret"},
)
assert resp.status_code == 200, resp.text
access = resp.json()["access_token"]
# A local profile was provisioned from the username with the default role.
async with db_sessionmaker() as db:
user = await user_crud.get_by_username(db, "jdoe")
assert user is not None
assert user.email == "jdoe@example.com"
assert user.full_name == "Jane Doe"
assert user.auth_source == "ldap"
assert user.hashed_password is None
assert {r.name for r in user.roles} == {"user"}
# The issued token authenticates.
me = await client.get(
"/api/users/me", headers={"Authorization": f"Bearer {access}"}
)
assert me.status_code == 200
assert me.json()["username"] == "jdoe"
async def test_ldap_login_is_idempotent(
client, seeded, db_sessionmaker, monkeypatch, ldap_enabled
):
async with db_sessionmaker() as db:
role = await role_crud.get(db, seeded["roles"]["user"].id)
await role_crud.set_default(db, role)
async def fake_authenticate(username, password):
return _fake_identity()
monkeypatch.setattr(ldap_service, "authenticate", fake_authenticate)
for _ in range(2):
resp = await client.post(
"/api/auth/ldap-login", data={"username": "jdoe", "password": "secret"}
)
assert resp.status_code == 200
async with db_sessionmaker() as db:
assert await user_crud.count(db) == 1
async def test_ldap_login_bad_credentials(
client, seeded, monkeypatch, ldap_enabled
):
async def fake_authenticate(username, password):
return None
monkeypatch.setattr(ldap_service, "authenticate", fake_authenticate)
resp = await client.post(
"/api/auth/ldap-login", data={"username": "jdoe", "password": "nope"}
)
assert resp.status_code == 401
async def test_ldap_login_disabled_returns_404(client, seeded):
# ldap_enabled defaults to False.
resp = await client.post(
"/api/auth/ldap-login", data={"username": "jdoe", "password": "secret"}
)
assert resp.status_code == 404
async def test_ldap_authenticate_short_circuits_when_disabled():
# No network call: disabled or empty credentials return None immediately.
assert await ldap_service.authenticate("jdoe", "secret") is None
async def test_set_default_role_is_exclusive(db_sessionmaker):
async with db_sessionmaker() as db:
a = await role_crud.create(db, name="alpha")
b = await role_crud.create(db, name="beta")
await role_crud.set_default(db, a)
assert (await role_crud.get_default(db)).id == a.id
# Marking another default clears the previous one.
await role_crud.set_default(db, b)
default = await role_crud.get_default(db)
assert default.id == b.id
await db.refresh(a)
assert a.is_default is False
async def test_local_login_rejects_ldap_user(
client, seeded, db_sessionmaker, monkeypatch, ldap_enabled
):
"""An LDAP-provisioned user (no local password) can't use the local login."""
async with db_sessionmaker() as db:
role = await role_crud.get(db, seeded["roles"]["user"].id)
await role_crud.set_default(db, role)
async def fake_authenticate(username, password):
return _fake_identity()
monkeypatch.setattr(ldap_service, "authenticate", fake_authenticate)
await client.post(
"/api/auth/ldap-login", data={"username": "jdoe", "password": "secret"}
)
# Local login with the synthesized email must fail (no local password).
resp = await client.post(
"/api/auth/login",
data={"username": "jdoe@example.com", "password": "secret"},
)
assert resp.status_code == 401
Generated
+23
View File
@@ -291,6 +291,7 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "ldap3" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
@@ -319,6 +320,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ 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" },
@@ -518,6 +520,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"
@@ -649,6 +663,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"