LDAP/AD-Auth fuer Dashboard: User-Bind gegen insight.local, Zugriff nur fuer Domänen-Admins
- ldap_auth Service: User bindet als user@domain (kein Servicekonto noetig), Gruppenpruefung ueber Matching-Rule-In-Chain (verschachtelte Gruppen) - Login-Route: lokaler User zuerst, dann LDAP-Fallback; LDAP-User werden lokal gespiegelt (is_ldap, is_admin, Profildaten aus AD) - Neue Config: ldap_domain, ldap_admin_group, ldap_timeout_seconds - ldap3 Dependency, 7 Unit-Tests (ldap3 gemockt), ruff+mypy sauber - Gruppen-DN verified am DC: CN=Domänen-Admins,CN=Users,DC=insight,DC=local
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"""Auth routes: login (local user or LDAP stub)."""
|
||||
"""Auth routes: login (local user or LDAP/AD)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import client_ip
|
||||
from app.core.config import get_settings
|
||||
@@ -14,11 +15,24 @@ from app.core.security import create_access_token, verify_password
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse
|
||||
from app.services.audit import AuditService
|
||||
from app.services.ldap_auth import LdapAuthError
|
||||
from app.services.ldap_auth import authenticate as ldap_authenticate
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _issue_token(user: User) -> TokenResponse:
|
||||
token = create_access_token(
|
||||
subject=user.username,
|
||||
extra_claims={"is_admin": user.is_admin},
|
||||
)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
@@ -36,16 +50,50 @@ async def login(
|
||||
action="auth.login",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
token = create_access_token(
|
||||
subject=user.username,
|
||||
extra_claims={"is_admin": user.is_admin},
|
||||
)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
return _issue_token(user)
|
||||
|
||||
# Local user not found or password mismatch: fall back to LDAP/AD when
|
||||
# enabled. Only members of the configured admin group get access.
|
||||
if settings.ldap_enabled:
|
||||
try:
|
||||
ldap_user = await run_in_threadpool(
|
||||
ldap_authenticate, payload.username, payload.password, settings
|
||||
)
|
||||
except LdapAuthError as exc:
|
||||
await audit.log(
|
||||
username=payload.username,
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
raise UnauthorizedError(exc.detail) from exc
|
||||
|
||||
result = await db.execute(select(User).where(User.username == ldap_user.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
username=ldap_user.username,
|
||||
full_name=ldap_user.full_name,
|
||||
email=ldap_user.email,
|
||||
is_ldap=True,
|
||||
is_admin=ldap_user.is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
user.is_ldap = True
|
||||
user.is_admin = ldap_user.is_admin
|
||||
if ldap_user.full_name:
|
||||
user.full_name = ldap_user.full_name
|
||||
if ldap_user.email:
|
||||
user.email = ldap_user.email
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await audit.log(
|
||||
username=user.username,
|
||||
action="auth.login",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return _issue_token(user)
|
||||
|
||||
# LDAP stub: when enabled, attempt bind + search here (not yet implemented)
|
||||
await audit.log(
|
||||
username=payload.username,
|
||||
action="auth.login",
|
||||
|
||||
@@ -35,13 +35,14 @@ class Settings(BaseSettings):
|
||||
# seconds is considered stale and marked failed by the janitor
|
||||
job_stale_timeout: int = 3600
|
||||
|
||||
# LDAP (stub)
|
||||
# LDAP / Active Directory (user bind, no service account)
|
||||
ldap_enabled: bool = False
|
||||
ldap_uri: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
ldap_domain: str = "" # bind as user@domain; empty = plain username bind
|
||||
ldap_user_search_base: str = ""
|
||||
ldap_user_filter: str = "(sAMAccountName={username})"
|
||||
ldap_admin_group: str = "" # DN, e.g. CN=Admins,CN=Users,DC=example,DC=com
|
||||
ldap_timeout_seconds: int = 10
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""LDAP/AD authentication for dashboard users.
|
||||
|
||||
User-bind design (no service account required):
|
||||
1. The user binds with their own credentials as ``user@domain`` (AD accepts
|
||||
userPrincipalName as bind DN).
|
||||
2. Over that authenticated connection we search the user's own directory
|
||||
entry, restricted by the in-chain matching rule to members of the
|
||||
configured admin group (direct or nested membership).
|
||||
|
||||
Only members of the configured group may log in. All other users are
|
||||
rejected before any local session is created.
|
||||
"""
|
||||
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
|
||||
import ldap3
|
||||
from ldap3.core.exceptions import LDAPExceptionError
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
# LDAP_MATCHING_RULE_IN_CHAIN: resolves nested group membership server-side
|
||||
_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1941"
|
||||
|
||||
|
||||
class LdapAuthError(Exception):
|
||||
"""Authentication failed. Message is safe to show to the user."""
|
||||
|
||||
def __init__(self, detail: str = "Benutzername oder Passwort falsch") -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass
|
||||
class LdapUser:
|
||||
username: str
|
||||
full_name: str | None
|
||||
email: str | None
|
||||
is_admin: bool # always True today: only admin-group members may log in
|
||||
|
||||
|
||||
def _build_server(settings: Settings) -> ldap3.Server:
|
||||
use_ssl = settings.ldap_uri.lower().startswith("ldaps://")
|
||||
# DC certificates come from an internal AD CA that is not distributed to
|
||||
# clients; full chain validation would need the enterprise root CA.
|
||||
tls = ldap3.Tls(validate=ssl.CERT_NONE) if use_ssl else None
|
||||
return ldap3.Server(
|
||||
settings.ldap_uri,
|
||||
get_info=ldap3.NONE,
|
||||
connect_timeout=settings.ldap_timeout_seconds,
|
||||
tls=tls,
|
||||
)
|
||||
|
||||
|
||||
def _bind_dn(username: str, settings: Settings) -> str:
|
||||
if settings.ldap_domain:
|
||||
return f"{username}@{settings.ldap_domain}"
|
||||
return username
|
||||
|
||||
|
||||
def authenticate(username: str, password: str, settings: Settings) -> LdapUser:
|
||||
"""Validate credentials against AD and verify admin-group membership.
|
||||
|
||||
Raises LdapAuthError on any failure (wrong password, unknown user,
|
||||
not in admin group, LDAP unreachable).
|
||||
"""
|
||||
if not settings.ldap_enabled:
|
||||
raise LdapAuthError("LDAP ist nicht aktiviert")
|
||||
if not settings.ldap_admin_group:
|
||||
raise LdapAuthError("LDAP-Admin-Gruppe ist nicht konfiguriert")
|
||||
if not username or not password:
|
||||
raise LdapAuthError()
|
||||
|
||||
server = _build_server(settings)
|
||||
try:
|
||||
conn = ldap3.Connection(
|
||||
server,
|
||||
user=_bind_dn(username.strip(), settings),
|
||||
password=password,
|
||||
auto_bind=True,
|
||||
receive_timeout=settings.ldap_timeout_seconds,
|
||||
)
|
||||
except LDAPExceptionError as exc:
|
||||
# Covers invalidCredentials (49) as well as connect/TLS timeouts
|
||||
raise LdapAuthError() from exc
|
||||
|
||||
with conn:
|
||||
user_filter = settings.ldap_user_filter.format(
|
||||
username=escape_filter_chars(username.strip())
|
||||
)
|
||||
# One search proves: user exists AND is (transitively) in the admin
|
||||
# group. Non-members simply match nothing.
|
||||
group_filter = f"(memberOf:{_MATCHING_RULE_IN_CHAIN}:={settings.ldap_admin_group})"
|
||||
ok = conn.search(
|
||||
search_base=settings.ldap_user_search_base,
|
||||
search_filter=f"(&{user_filter}{group_filter})",
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=["displayName", "mail"],
|
||||
)
|
||||
if not ok or not conn.entries:
|
||||
raise LdapAuthError("Kein Zugriff: Kein Mitglied der Administrator-Gruppe")
|
||||
entry = conn.entries[0]
|
||||
|
||||
return LdapUser(
|
||||
username=username.strip(),
|
||||
full_name=str(entry.displayName) if entry.displayName else None,
|
||||
email=str(entry.mail) if entry.mail else None,
|
||||
is_admin=True,
|
||||
)
|
||||
Reference in New Issue
Block a user