Adding LDAP login
This commit is contained in:
1 parent
ab39191af5
commit
cda15df2ed
22 files changed
+582
-15
No files matched your search
+32
-3
@@ -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:
|
||||
|
||||
@@ -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] = ["*"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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,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,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 %}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user