Files
Octop/tests/unit/api/test_client_ip.py
Suroyandjubaoliang 83b2ae2e73 feat(auth): add LDAP directory login (#1123)
Let users sign in with their directory (Active Directory / OpenLDAP)
credentials through the existing login form. Reuses sso_providers
(kind='ldap') and user_sso_identities, so no schema change is needed.

Authentication is search-then-bind: a service account resolves the user and
only the returned DN is bound with the submitted password, so user input is
never used as a bind identity. Roles come from a role template at first
provisioning only; changing a directory group later never re-templates an
existing account. Directory outages and wrong passwords stay distinguishable
(502 LDAP_UNAVAILABLE vs 401 AUTH_FAILED).

Hardening from review:

- An account holding its own password stops at the local check, so a local
  secret is never forwarded to the directory nor counted against that
  directory's own lockout policy.
- A lookup without a unique exact identifier match is refused instead of
  being bound against the first hit.
- A failure during the user bind surfaces as LDAP_UNAVAILABLE rather than a
  401 credential verdict.
- Binds are throttled before they reach the directory, keyed per identifier
  and client address, with the address resolved from a trusted peer so a
  spoofed X-Forwarded-For cannot reset the budget.
- Enabling a plain ldap:// URL without StartTLS is rejected; a disabled
  certificate check is surfaced as a warning.
- auto_provision defaults to off, with an optional allowed_groups allow-list
  and a group-search mode (member / uniqueMember / memberUid) for directories
  that do not expose memberOf. Nested groups are not resolved.
- The identity key is configurable (entryUUID / objectGUID) and blank by
  default; a connectivity probe reports which attribute the directory
  exposes.
- Config changes are audited against the acting admin, and referrals are no
  longer followed during binds.

Tests cover the above without a live directory (ldap3.Connection is the only
thing replaced); a live test exercises a real directory when configured.

Co-authored-by: jubaoliang <jubaoliang@gmail.com>
2026-10-02 22:39:13 +08:00

72 lines
2.5 KiB
Python

"""Client-address resolution for rate limiting.
The header is caller-supplied, so trusting it unconditionally would let anyone
reset a throttle by varying it. These cases pin the trust rules.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from octop.api.common.client_ip import UNKNOWN_CLIENT, resolve_client_ip
@dataclass
class _Client:
host: str | None = None
@dataclass
class _Request:
"""Minimal ASGI request surface used by the resolver."""
peer: str | None = None
forwarded_for: str | None = None
client: Any = field(init=False)
headers: dict[str, str] = field(init=False, default_factory=dict)
def __post_init__(self) -> None:
self.client = _Client(self.peer) if self.peer is not None else None
if self.forwarded_for is not None:
self.headers = {"x-forwarded-for": self.forwarded_for}
def test_direct_peer_is_used_and_the_header_is_ignored():
"""A client talking straight to us cannot name its own address."""
request = _Request(peer="203.0.113.7", forwarded_for="10.0.0.1")
assert resolve_client_ip(request) == "203.0.113.7"
def test_loopback_peer_may_forward_the_client_address():
request = _Request(peer="127.0.0.1", forwarded_for="203.0.113.7")
assert resolve_client_ip(request) == "203.0.113.7"
def test_forwarded_chain_takes_the_right_most_hop():
"""A trusted proxy appends what it saw; values to its left may be forged."""
request = _Request(peer="::1", forwarded_for="1.2.3.4, 203.0.113.7")
assert resolve_client_ip(request) == "203.0.113.7"
def test_a_client_supplied_prefix_cannot_choose_its_own_bucket():
"""The attacker controls the left of the chain, not the appended hop."""
honest = _Request(peer="127.0.0.1", forwarded_for="203.0.113.7")
forged = _Request(peer="127.0.0.1", forwarded_for="9.9.9.9, 203.0.113.7")
assert resolve_client_ip(forged) == resolve_client_ip(honest)
def test_loopback_peer_without_the_header_falls_back_to_the_peer():
assert resolve_client_ip(_Request(peer="127.0.0.1")) == "127.0.0.1"
def test_missing_peer_collapses_to_a_single_bucket():
"""Better one shared bucket than a fresh one per request."""
assert resolve_client_ip(_Request()) == UNKNOWN_CLIENT
assert resolve_client_ip(_Request(peer="")) == UNKNOWN_CLIENT
def test_blank_header_entries_are_ignored():
request = _Request(peer="127.0.0.1", forwarded_for=" , ")
assert resolve_client_ip(request) == "127.0.0.1"