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:
+11
-6
@@ -35,14 +35,19 @@ DB_PASSWORD=change-me-db-password
|
|||||||
JOB_STALE_TIMEOUT=3600
|
JOB_STALE_TIMEOUT=3600
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# LDAP / ACTIVE DIRECTORY (STUB - prepared for future implementation)
|
# LDAP / ACTIVE DIRECTORY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
LDAP_ENABLED=false
|
# User-Bind: Es gibt kein Servicekonto. Der User bindet mit seinen eigenen
|
||||||
LDAP_URI=ldaps://dc.insight.local:636
|
# Credentials (user@domain), Gruppenmitgliedschaft wird ueber die eigene
|
||||||
LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
|
# Verbindung geprueft. Nur Mitglieder der Admin-Gruppe kommen rein.
|
||||||
LDAP_BIND_PASSWORD=
|
LDAP_ENABLED=true
|
||||||
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
|
LDAP_URI=ldaps://ads-server-01.insight.local:636
|
||||||
|
LDAP_DOMAIN=insight.local
|
||||||
|
LDAP_USER_SEARCH_BASE=DC=insight,DC=local
|
||||||
LDAP_USER_FILTER=(sAMAccountName={username})
|
LDAP_USER_FILTER=(sAMAccountName={username})
|
||||||
|
# Achtung: DN exakt wie im AD (verified 2026-09-28, Umlaut beachten):
|
||||||
|
LDAP_ADMIN_GROUP=CN=Domänen-Admins,CN=Users,DC=insight,DC=local
|
||||||
|
LDAP_TIMEOUT_SECONDS=10
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# FRONTEND (injected at build time via Vite)
|
# FRONTEND (injected at build time via Vite)
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ Kundennetz A Kundennetz B Zentrale
|
|||||||
- **Netzwerk-Scan**: Ping + Port-Probe (5985/22), legt gefundene Hosts zentral an
|
- **Netzwerk-Scan**: Ping + Port-Probe (5985/22), legt gefundene Hosts zentral an
|
||||||
- **Log-Upload** in Batches (kein Live-Stream noetig, 10s Dashboard-Refresh)
|
- **Log-Upload** in Batches (kein Live-Stream noetig, 10s Dashboard-Refresh)
|
||||||
- **Audit-Log**: strukturiert, pro Kunde filterbar
|
- **Audit-Log**: strukturiert, pro Kunde filterbar
|
||||||
- **Auth**: JWT (RS256) fuer Dashboard-User, API-Key (SHA-256 gehasht) fuer Satelliten
|
- **Auth**: JWT (RS256) fuer Dashboard-User, API-Key (SHA-256 gehasht) fuer Satelliten;
|
||||||
|
Dashboard-Login optional per LDAP/AD (User-Bind, kein Servicekonto) mit
|
||||||
|
Zugriffsbeschraenkung auf die konfigurierte Admin-Gruppe
|
||||||
|
|
||||||
## Quick Start (Zentrale, Development)
|
## Quick Start (Zentrale, Development)
|
||||||
|
|
||||||
@@ -94,8 +96,16 @@ Wichtig: die Zentrale muss per HTTPS von den Kunden-Standorten aus erreichbar se
|
|||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
|
|
||||||
Siehe `.env.example`. Zentrale braucht nur noch: SECRET_KEY, DATABASE_URL, JWT-Keys,
|
Siehe `.env.example`. Zentrale braucht nur noch: SECRET_KEY, DATABASE_URL, JWT-Keys.
|
||||||
optional LDAP. WinRM/SSH-Config ist in den Satellite gewandert (dessen `config.yaml`).
|
WinRM/SSH-Config ist in den Satellite gewandert (dessen `config.yaml`).
|
||||||
|
|
||||||
|
## LDAP / Active Directory
|
||||||
|
|
||||||
|
Dashboard-Login per AD (User-Bind, kein Servicekonto): Der User bindet mit
|
||||||
|
seinen eigenen Credentials als `user@domain`, die Gruppenmitgliedschaft wird
|
||||||
|
ueber die Matching-Rule-In-Chain geprueft (verschachtelte Gruppen aufloesend).
|
||||||
|
Nur Mitglieder von `LDAP_ADMIN_GROUP` bekommen Zugang. Lokal angelegte User
|
||||||
|
(Initial-Admin) bleiben als Fallback wirksam.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -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 datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.deps import client_ip
|
from app.api.deps import client_ip
|
||||||
from app.core.config import get_settings
|
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.models.user import User
|
||||||
from app.schemas.auth import LoginRequest, TokenResponse
|
from app.schemas.auth import LoginRequest, TokenResponse
|
||||||
from app.services.audit import AuditService
|
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()
|
settings = get_settings()
|
||||||
router = APIRouter()
|
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)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(
|
async def login(
|
||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
@@ -36,16 +50,50 @@ async def login(
|
|||||||
action="auth.login",
|
action="auth.login",
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
token = create_access_token(
|
return _issue_token(user)
|
||||||
subject=user.username,
|
|
||||||
extra_claims={"is_admin": user.is_admin},
|
# Local user not found or password mismatch: fall back to LDAP/AD when
|
||||||
)
|
# enabled. Only members of the configured admin group get access.
|
||||||
return TokenResponse(
|
if settings.ldap_enabled:
|
||||||
access_token=token,
|
try:
|
||||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
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(
|
await audit.log(
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
action="auth.login",
|
action="auth.login",
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ class Settings(BaseSettings):
|
|||||||
# seconds is considered stale and marked failed by the janitor
|
# seconds is considered stale and marked failed by the janitor
|
||||||
job_stale_timeout: int = 3600
|
job_stale_timeout: int = 3600
|
||||||
|
|
||||||
# LDAP (stub)
|
# LDAP / Active Directory (user bind, no service account)
|
||||||
ldap_enabled: bool = False
|
ldap_enabled: bool = False
|
||||||
ldap_uri: str = ""
|
ldap_uri: str = ""
|
||||||
ldap_bind_dn: str = ""
|
ldap_domain: str = "" # bind as user@domain; empty = plain username bind
|
||||||
ldap_bind_password: str = ""
|
|
||||||
ldap_user_search_base: str = ""
|
ldap_user_search_base: str = ""
|
||||||
ldap_user_filter: str = "(sAMAccountName={username})"
|
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
|
||||||
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
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,
|
||||||
|
)
|
||||||
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.6",
|
"python-multipart>=0.0.6",
|
||||||
"httpx>=0.26.0",
|
"httpx>=0.26.0",
|
||||||
"tenacity>=8.2.0",
|
"tenacity>=8.2.0",
|
||||||
|
"ldap3>=2.9.1",
|
||||||
"pytz>=2024.1",
|
"pytz>=2024.1",
|
||||||
"python-dateutil>=2.8.2",
|
"python-dateutil>=2.8.2",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Unit tests for LDAP user-bind authentication (ldap3 fully mocked)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.services.ldap_auth import LdapAuthError, authenticate
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: object) -> Settings:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"ldap_enabled": True,
|
||||||
|
"ldap_uri": "ldaps://ads-server-01.insight.local:636",
|
||||||
|
"ldap_domain": "insight.local",
|
||||||
|
"ldap_user_search_base": "DC=insight,DC=local",
|
||||||
|
"ldap_user_filter": "(sAMAccountName={username})",
|
||||||
|
"ldap_admin_group": "CN=Domain Admins,CN=Users,DC=insight,DC=local",
|
||||||
|
"ldap_timeout_seconds": 10,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return Settings(**base) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(full_name: str = "Flo Hartmann", email: str = "flo@example.com") -> MagicMock:
|
||||||
|
entry = MagicMock()
|
||||||
|
entry.displayName = full_name
|
||||||
|
entry.mail = email
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_ldap(monkeypatch: pytest.MonkeyPatch, conn: MagicMock) -> None:
|
||||||
|
mock_ldap3 = MagicMock()
|
||||||
|
mock_ldap3.Server.return_value = MagicMock()
|
||||||
|
mock_ldap3.Connection.return_value = conn
|
||||||
|
mock_ldap3.SUBTREE = "SUBTREE"
|
||||||
|
mock_ldap3.NONE = "NO_INFO"
|
||||||
|
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
|
||||||
|
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_raises() -> None:
|
||||||
|
with pytest.raises(LdapAuthError, match="nicht aktiviert"):
|
||||||
|
authenticate("flo", "secret", _settings(ldap_enabled=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_admin_group_raises() -> None:
|
||||||
|
with pytest.raises(LdapAuthError, match="nicht konfiguriert"):
|
||||||
|
authenticate("flo", "secret", _settings(ldap_admin_group=""))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_password_raises() -> None:
|
||||||
|
with pytest.raises(LdapAuthError):
|
||||||
|
authenticate("flo", "", _settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_failure_raises_generic(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from ldap3.core.exceptions import LDAPExceptionError
|
||||||
|
|
||||||
|
mock_ldap3 = MagicMock()
|
||||||
|
mock_ldap3.Server.return_value = MagicMock()
|
||||||
|
mock_ldap3.Connection.side_effect = LDAPExceptionError("invalidCredentials")
|
||||||
|
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
|
||||||
|
|
||||||
|
with pytest.raises(LdapAuthError, match="Benutzername oder Passwort falsch"):
|
||||||
|
authenticate("flo", "wrong", _settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_without_match_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.__enter__.return_value = conn
|
||||||
|
conn.search.return_value = True
|
||||||
|
conn.entries = []
|
||||||
|
_patch_ldap(monkeypatch, conn)
|
||||||
|
|
||||||
|
with pytest.raises(LdapAuthError, match="Kein Zugriff"):
|
||||||
|
authenticate("flo", "secret", _settings())
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_returns_admin_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.__enter__.return_value = conn
|
||||||
|
conn.search.return_value = True
|
||||||
|
conn.entries = [_entry()]
|
||||||
|
mock_ldap3 = MagicMock()
|
||||||
|
mock_ldap3.Server.return_value = MagicMock()
|
||||||
|
mock_ldap3.Connection.return_value = conn
|
||||||
|
mock_ldap3.SUBTREE = "SUBTREE"
|
||||||
|
mock_ldap3.NONE = "NO_INFO"
|
||||||
|
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
|
||||||
|
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
|
||||||
|
|
||||||
|
user = authenticate("flo", "secret", _settings())
|
||||||
|
|
||||||
|
assert user.username == "flo"
|
||||||
|
assert user.full_name == "Flo Hartmann"
|
||||||
|
assert user.email == "flo@example.com"
|
||||||
|
assert user.is_admin is True
|
||||||
|
# bind DN uses user@domain format
|
||||||
|
assert mock_ldap3.Connection.call_args.kwargs["user"] == "flo@insight.local"
|
||||||
|
# search filter proves admin-group membership via in-chain rule
|
||||||
|
search_kwargs = conn.search.call_args.kwargs
|
||||||
|
assert "memberOf:1.2.840.113556.1941:=" in search_kwargs["search_filter"]
|
||||||
|
assert "CN=Domain Admins,CN=Users,DC=insight,DC=local" in search_kwargs["search_filter"]
|
||||||
|
assert search_kwargs["search_base"] == "DC=insight,DC=local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_fields_none_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.__enter__.return_value = conn
|
||||||
|
conn.search.return_value = True
|
||||||
|
conn.entries = [_entry(full_name="", email="")]
|
||||||
|
mock_ldap3 = MagicMock()
|
||||||
|
mock_ldap3.Server.return_value = MagicMock()
|
||||||
|
mock_ldap3.Connection.return_value = conn
|
||||||
|
mock_ldap3.SUBTREE = "SUBTREE"
|
||||||
|
mock_ldap3.NONE = "NO_INFO"
|
||||||
|
mock_ldap3.utils.conv.escape_filter_chars = lambda s: s
|
||||||
|
monkeypatch.setattr("app.services.ldap_auth.ldap3", mock_ldap3)
|
||||||
|
|
||||||
|
user = authenticate("flo", "secret", _settings())
|
||||||
|
assert user.full_name is None
|
||||||
|
assert user.email is None
|
||||||
Reference in New Issue
Block a user