feat: add directory adapter
LDAPS directory adapter with certificate verification, allow-listed reads and writes, escaped filters, password verification and change, password policy and account state, plus an in-memory fake. Adds the LDAP_* configuration, python-ldap with its image libraries, the CA file mount and an opt-in live test for the test OU. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""Directory adapter: the only code that talks to Active Directory (AD-1)."""
|
||||
|
||||
from .base import (
|
||||
READABLE_ATTRIBUTES,
|
||||
WRITABLE_ATTRIBUTES,
|
||||
Directory,
|
||||
DirectoryError,
|
||||
DirectoryUnavailable,
|
||||
DirectoryUser,
|
||||
Forbidden,
|
||||
PasswordPolicy,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
)
|
||||
from .fake import FakeDirectory
|
||||
from .ldap_directory import LdapDirectory
|
||||
|
||||
__all__ = [
|
||||
"READABLE_ATTRIBUTES",
|
||||
"WRITABLE_ATTRIBUTES",
|
||||
"Directory",
|
||||
"DirectoryError",
|
||||
"DirectoryUnavailable",
|
||||
"DirectoryUser",
|
||||
"FakeDirectory",
|
||||
"Forbidden",
|
||||
"LdapDirectory",
|
||||
"PasswordPolicy",
|
||||
"PasswordRejected",
|
||||
"ValidationFailed",
|
||||
]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""The directory interface, its data types and its typed errors (AD-1, AD-2, AD-17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
# Fixed allow-lists (AD-2). unicodePwd is writable only through change_password.
|
||||
WRITABLE_ATTRIBUTES = (
|
||||
"givenName",
|
||||
"sn",
|
||||
"displayName",
|
||||
"mail",
|
||||
"telephoneNumber",
|
||||
"mobile",
|
||||
)
|
||||
READABLE_ATTRIBUTES = (
|
||||
*WRITABLE_ATTRIBUTES,
|
||||
"sAMAccountName",
|
||||
"memberOf",
|
||||
"thumbnailPhoto",
|
||||
"pwdLastSet",
|
||||
"userAccountControl",
|
||||
"msDS-User-Account-Control-Computed",
|
||||
"lockoutTime",
|
||||
"msDS-ResultantPSO",
|
||||
)
|
||||
POLICY_ATTRIBUTES = (
|
||||
"minPwdLength",
|
||||
"pwdProperties",
|
||||
"pwdHistoryLength",
|
||||
"minPwdAge",
|
||||
"maxPwdAge",
|
||||
"lockoutDuration",
|
||||
)
|
||||
PSO_ATTRIBUTES = (
|
||||
"msDS-MinimumPasswordLength",
|
||||
"msDS-PasswordComplexityEnabled",
|
||||
"msDS-PasswordHistoryLength",
|
||||
"msDS-MinimumPasswordAge",
|
||||
"msDS-MaximumPasswordAge",
|
||||
)
|
||||
|
||||
|
||||
class DirectoryError(Exception):
|
||||
"""Base class of every error the directory adapter raises."""
|
||||
|
||||
|
||||
class DirectoryUnavailable(DirectoryError):
|
||||
"""The directory cannot be reached or refused the service account."""
|
||||
|
||||
|
||||
class Forbidden(DirectoryError):
|
||||
"""The request is outside the allow-list or the service account lacks the right."""
|
||||
|
||||
|
||||
class ValidationFailed(DirectoryError):
|
||||
"""The directory refused a value."""
|
||||
|
||||
|
||||
class PasswordRejected(ValidationFailed):
|
||||
"""A password change was refused; `reason` is `wrong_current` or `policy`."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"Password change refused: {reason}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryUser:
|
||||
sam: str # exact sAMAccountName
|
||||
given_name: str = ""
|
||||
surname: str = ""
|
||||
display_name: str = ""
|
||||
mail: str = ""
|
||||
telephone: str = ""
|
||||
mobile: str = ""
|
||||
enabled: bool = True
|
||||
locked: bool = False
|
||||
password_last_set: datetime | None = None # UTC; None when the user must change it at sign-in
|
||||
is_publisher: bool = False
|
||||
is_admin: bool = False
|
||||
|
||||
@property
|
||||
def must_change_password(self) -> bool:
|
||||
return self.password_last_set is None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PasswordPolicy:
|
||||
min_length: int
|
||||
complexity: bool
|
||||
history_length: int
|
||||
min_age: timedelta
|
||||
max_age: timedelta | None # None: passwords do not expire
|
||||
fine_grained: bool = False # a fine-grained policy applies to the user
|
||||
fine_grained_readable: bool = True # False: the values above are the domain's, not the user's
|
||||
|
||||
|
||||
class Directory(Protocol):
|
||||
"""The only way the rest of the portal talks to the directory."""
|
||||
|
||||
def find_user(self, sam: str) -> DirectoryUser | None:
|
||||
"""Look the user up by sAMAccountName inside the configured OU."""
|
||||
|
||||
def verify_password(self, sam: str, password: str) -> bool:
|
||||
"""True when the user can bind with this password. Empty passwords are never valid."""
|
||||
|
||||
def password_policy(self, sam: str) -> PasswordPolicy:
|
||||
"""The policy that applies to this user (the domain's, or a fine-grained one)."""
|
||||
|
||||
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
|
||||
"""Replace allow-listed attributes; None or an empty string clears one."""
|
||||
|
||||
def change_password(self, sam: str, current: str, new: str) -> None:
|
||||
"""Change the password as the user does, which needs the current password."""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""In-memory directory with the same rules as the real one, for tests (AD-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from .base import (
|
||||
WRITABLE_ATTRIBUTES,
|
||||
DirectoryUnavailable,
|
||||
DirectoryUser,
|
||||
Forbidden,
|
||||
PasswordPolicy,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
)
|
||||
|
||||
_FIELD = {
|
||||
"givenName": "given_name",
|
||||
"sn": "surname",
|
||||
"displayName": "display_name",
|
||||
"mail": "mail",
|
||||
"telephoneNumber": "telephone",
|
||||
"mobile": "mobile",
|
||||
}
|
||||
DEFAULT_POLICY = PasswordPolicy(
|
||||
min_length=8,
|
||||
complexity=True,
|
||||
history_length=24,
|
||||
min_age=timedelta(0),
|
||||
max_age=timedelta(days=90),
|
||||
)
|
||||
|
||||
|
||||
def _character_classes(password: str) -> int:
|
||||
tests = (str.isupper, str.islower, str.isdigit, lambda c: not c.isalnum())
|
||||
return sum(any(test(c) for c in password) for test in tests)
|
||||
|
||||
|
||||
class FakeDirectory:
|
||||
def __init__(self, policy: PasswordPolicy = DEFAULT_POLICY) -> None:
|
||||
self._users: dict[str, DirectoryUser] = {}
|
||||
self._passwords: dict[str, str] = {}
|
||||
self._history: dict[str, list[str]] = {}
|
||||
self.policy = policy
|
||||
self.unavailable = False # set True to simulate an outage
|
||||
|
||||
def add_user(self, sam: str, password: str = "Correct-Horse-9", **fields) -> DirectoryUser: # noqa: S107
|
||||
fields.setdefault("password_last_set", datetime.now(UTC))
|
||||
user = DirectoryUser(sam=sam, **fields)
|
||||
self._users[sam.casefold()] = user
|
||||
self._passwords[sam.casefold()] = password
|
||||
self._history[sam.casefold()] = [password]
|
||||
return user
|
||||
|
||||
def _check_up(self) -> None:
|
||||
if self.unavailable:
|
||||
raise DirectoryUnavailable("The directory is not available")
|
||||
|
||||
def _get(self, sam: str) -> DirectoryUser:
|
||||
user = self._users.get(sam.casefold()) if sam else None
|
||||
if user is None:
|
||||
raise ValidationFailed("The user does not exist in the configured OU")
|
||||
return user
|
||||
|
||||
def find_user(self, sam: str) -> DirectoryUser | None:
|
||||
self._check_up()
|
||||
return self._users.get(sam.casefold()) if sam else None
|
||||
|
||||
def verify_password(self, sam: str, password: str) -> bool:
|
||||
self._check_up()
|
||||
if not password or not sam:
|
||||
return False
|
||||
user = self._users.get(sam.casefold())
|
||||
return user is not None and self._passwords[sam.casefold()] == password
|
||||
|
||||
def password_policy(self, sam: str) -> PasswordPolicy:
|
||||
self._check_up()
|
||||
self._get(sam)
|
||||
return self.policy
|
||||
|
||||
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
|
||||
self._check_up()
|
||||
refused = [name for name in changes if name not in WRITABLE_ATTRIBUTES]
|
||||
if refused:
|
||||
raise Forbidden(f"Attributes are not writable: {', '.join(sorted(refused))}")
|
||||
user = self._get(sam)
|
||||
values = {}
|
||||
for name, value in changes.items():
|
||||
if value is not None and (not isinstance(value, str) or "\x00" in value):
|
||||
raise ValidationFailed(f"Invalid value for {name}")
|
||||
values[_FIELD[name]] = value or ""
|
||||
self._users[sam.casefold()] = replace(user, **values)
|
||||
|
||||
def change_password(self, sam: str, current: str, new: str) -> None:
|
||||
self._check_up()
|
||||
user = self._get(sam)
|
||||
key = sam.casefold()
|
||||
if not current or self._passwords[key] != current:
|
||||
raise PasswordRejected("wrong_current")
|
||||
age = datetime.now(UTC) - user.password_last_set if user.password_last_set else None
|
||||
too_young = age is not None and age < self.policy.min_age
|
||||
weak = len(new) < self.policy.min_length or (
|
||||
self.policy.complexity and _character_classes(new) < 3
|
||||
)
|
||||
reused = new in self._history[key][-self.policy.history_length :]
|
||||
if not new or too_young or weak or reused:
|
||||
raise PasswordRejected("policy")
|
||||
self._passwords[key] = new
|
||||
self._history[key].append(new)
|
||||
self._users[key] = replace(user, password_last_set=datetime.now(UTC))
|
||||
@@ -0,0 +1,287 @@
|
||||
"""The LDAPS implementation. The only module in the portal that imports `ldap` (AD-1, AD-2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import ModuleType
|
||||
|
||||
from portal.config.env import DirectoryConfig
|
||||
|
||||
from .base import (
|
||||
POLICY_ATTRIBUTES,
|
||||
PSO_ATTRIBUTES,
|
||||
READABLE_ATTRIBUTES,
|
||||
WRITABLE_ATTRIBUTES,
|
||||
DirectoryUnavailable,
|
||||
DirectoryUser,
|
||||
Forbidden,
|
||||
PasswordPolicy,
|
||||
PasswordRejected,
|
||||
ValidationFailed,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_SECONDS = 10
|
||||
UF_ACCOUNTDISABLE = 0x2
|
||||
UF_LOCKOUT = 0x10
|
||||
COMPLEX_PASSWORDS = 0x1
|
||||
# Windows FILETIME counts 100 ns ticks since 1601-01-01.
|
||||
_FILETIME_EPOCH = datetime(1601, 1, 1, tzinfo=UTC)
|
||||
_NEVER = -(2**63)
|
||||
_AD_ERROR_CODE = re.compile(r"\b0000([0-9A-Fa-f]{4}):")
|
||||
_WRONG_CURRENT_CODE = "0056" # ERROR_INVALID_PASSWORD; a policy refusal is 052D
|
||||
|
||||
|
||||
def _load_ldap() -> ModuleType:
|
||||
# Imported on first use so the rest of the portal (and its tests) load without the C library.
|
||||
import ldap
|
||||
import ldap.filter
|
||||
|
||||
return ldap
|
||||
|
||||
|
||||
def _normalize_dn(dn: str) -> str:
|
||||
return re.sub(r"\s*([,=])\s*", r"\1", dn).casefold()
|
||||
|
||||
|
||||
def _text(attrs: dict[str, list[bytes]], name: str) -> str:
|
||||
values = attrs.get(name)
|
||||
return values[0].decode("utf-8") if values else ""
|
||||
|
||||
|
||||
def _filetime(value: str) -> datetime | None:
|
||||
ticks = int(value or 0)
|
||||
if ticks <= 0:
|
||||
return None
|
||||
return _FILETIME_EPOCH + timedelta(microseconds=ticks // 10)
|
||||
|
||||
|
||||
def _interval(value: str) -> timedelta | None:
|
||||
"""AD stores durations as negative 100 ns ticks; 0 and the minimum value mean 'never'."""
|
||||
ticks = int(value or 0)
|
||||
if ticks == 0 or ticks == _NEVER:
|
||||
return None
|
||||
return timedelta(microseconds=abs(ticks) // 10)
|
||||
|
||||
|
||||
class LdapDirectory:
|
||||
def __init__(self, config: DirectoryConfig, ldap_module: ModuleType | None = None) -> None:
|
||||
self._config = config
|
||||
self._ldap = ldap_module or _load_ldap()
|
||||
host = config.server
|
||||
self._url = f"ldaps://{host}" if ":" in host else f"ldaps://{host}:636"
|
||||
|
||||
# -- connections -----------------------------------------------------------
|
||||
|
||||
def _connect(self):
|
||||
ldap = self._ldap
|
||||
conn = ldap.initialize(self._url)
|
||||
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
conn.set_option(ldap.OPT_REFERRALS, 0)
|
||||
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, TIMEOUT_SECONDS)
|
||||
conn.set_option(ldap.OPT_TIMEOUT, TIMEOUT_SECONDS)
|
||||
conn.set_option(ldap.OPT_X_TLS_CACERTFILE, str(self._config.ca_file))
|
||||
conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
|
||||
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # apply the options above to this connection
|
||||
return conn
|
||||
|
||||
@contextmanager
|
||||
def _service(self):
|
||||
"""A bound connection of the service account; library errors become typed errors."""
|
||||
ldap = self._ldap
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.simple_bind_s(self._config.bind_dn, self._config.bind_password)
|
||||
yield conn
|
||||
except ldap.INSUFFICIENT_ACCESS as exc:
|
||||
raise Forbidden("The service account lacks the right for this request") from exc
|
||||
except ldap.LDAPError as exc:
|
||||
# Name the class only: messages can echo a bind DN or a filter.
|
||||
logger.warning("Directory request failed: %s", type(exc).__name__)
|
||||
raise DirectoryUnavailable("The directory is not available") from exc
|
||||
finally:
|
||||
try:
|
||||
conn.unbind_s()
|
||||
except self._ldap.LDAPError:
|
||||
pass
|
||||
|
||||
def _search(self, conn, base: str, scope: int, flt: str, attrs: tuple[str, ...]):
|
||||
found = conn.search_s(base, scope, flt, list(attrs))
|
||||
return [(dn, a) for dn, a in found if dn is not None] # drop referrals
|
||||
|
||||
def _find_entry(self, conn, sam: str):
|
||||
"""The (dn, attributes) of the user in the configured OU, or None."""
|
||||
if not sam:
|
||||
return None
|
||||
ldap = self._ldap
|
||||
escaped = ldap.filter.escape_filter_chars(sam)
|
||||
flt = f"(&(objectClass=user)(objectCategory=person)(sAMAccountName={escaped}))"
|
||||
entries = self._search(
|
||||
conn, self._config.user_ou_dn, ldap.SCOPE_SUBTREE, flt, READABLE_ATTRIBUTES
|
||||
)
|
||||
if len(entries) != 1:
|
||||
if len(entries) > 1:
|
||||
logger.warning("Directory lookup was ambiguous; treating the user as not found")
|
||||
return None
|
||||
return entries[0]
|
||||
|
||||
def _require_entry(self, conn, sam: str):
|
||||
entry = self._find_entry(conn, sam)
|
||||
if entry is None:
|
||||
raise ValidationFailed("The user does not exist in the configured OU")
|
||||
return entry
|
||||
|
||||
# -- interface ---------------------------------------------------------------
|
||||
|
||||
def find_user(self, sam: str) -> DirectoryUser | None:
|
||||
with self._service() as conn:
|
||||
entry = self._find_entry(conn, sam)
|
||||
if entry is None:
|
||||
return None
|
||||
attrs = entry[1]
|
||||
computed = _text(attrs, "msDS-User-Account-Control-Computed")
|
||||
if computed:
|
||||
locked = bool(int(computed) & UF_LOCKOUT)
|
||||
else:
|
||||
locked = self._locked_by_time(conn, _text(attrs, "lockoutTime"))
|
||||
groups = {_normalize_dn(v.decode("utf-8")) for v in attrs.get("memberOf", [])}
|
||||
return DirectoryUser(
|
||||
sam=_text(attrs, "sAMAccountName"),
|
||||
given_name=_text(attrs, "givenName"),
|
||||
surname=_text(attrs, "sn"),
|
||||
display_name=_text(attrs, "displayName"),
|
||||
mail=_text(attrs, "mail"),
|
||||
telephone=_text(attrs, "telephoneNumber"),
|
||||
mobile=_text(attrs, "mobile"),
|
||||
enabled=not int(_text(attrs, "userAccountControl") or 0) & UF_ACCOUNTDISABLE,
|
||||
locked=locked,
|
||||
password_last_set=_filetime(_text(attrs, "pwdLastSet")),
|
||||
is_publisher=_normalize_dn(self._config.publisher_group_dn) in groups,
|
||||
is_admin=_normalize_dn(self._config.admin_group_dn) in groups,
|
||||
)
|
||||
|
||||
def _locked_by_time(self, conn, lockout_time: str) -> bool:
|
||||
"""Fallback when the computed account-control attribute is not readable."""
|
||||
locked_at = _filetime(lockout_time)
|
||||
if locked_at is None:
|
||||
return False
|
||||
domain_dn = self._domain_dn(conn)
|
||||
policy = self._search(
|
||||
conn, domain_dn, self._ldap.SCOPE_BASE, "(objectClass=*)", POLICY_ATTRIBUTES
|
||||
)
|
||||
duration = _interval(_text(policy[0][1], "lockoutDuration")) if policy else None
|
||||
if duration is None: # 'never unlock': only an administrator clears the lock
|
||||
return True
|
||||
return datetime.now(UTC) < locked_at + duration
|
||||
|
||||
def _domain_dn(self, conn) -> str:
|
||||
root = self._search(
|
||||
conn, "", self._ldap.SCOPE_BASE, "(objectClass=*)", ("defaultNamingContext",)
|
||||
)
|
||||
return _text(root[0][1], "defaultNamingContext")
|
||||
|
||||
def verify_password(self, sam: str, password: str) -> bool:
|
||||
if not password:
|
||||
return False # an empty password would be an anonymous bind, which succeeds
|
||||
ldap = self._ldap
|
||||
with self._service() as conn:
|
||||
entry = self._find_entry(conn, sam)
|
||||
if entry is None:
|
||||
return False
|
||||
user_conn = self._connect()
|
||||
try:
|
||||
user_conn.simple_bind_s(entry[0], password)
|
||||
return True
|
||||
except ldap.INVALID_CREDENTIALS:
|
||||
return False
|
||||
except ldap.LDAPError as exc:
|
||||
logger.warning("Directory request failed: %s", type(exc).__name__)
|
||||
raise DirectoryUnavailable("The directory is not available") from exc
|
||||
finally:
|
||||
try:
|
||||
user_conn.unbind_s()
|
||||
except ldap.LDAPError:
|
||||
pass
|
||||
|
||||
def password_policy(self, sam: str) -> PasswordPolicy:
|
||||
ldap = self._ldap
|
||||
with self._service() as conn:
|
||||
entry = self._require_entry(conn, sam)
|
||||
domain_dn = self._domain_dn(conn)
|
||||
domain = self._search(
|
||||
conn, domain_dn, ldap.SCOPE_BASE, "(objectClass=*)", POLICY_ATTRIBUTES
|
||||
)[0][1]
|
||||
policy = PasswordPolicy(
|
||||
min_length=int(_text(domain, "minPwdLength") or 0),
|
||||
complexity=bool(int(_text(domain, "pwdProperties") or 0) & COMPLEX_PASSWORDS),
|
||||
history_length=int(_text(domain, "pwdHistoryLength") or 0),
|
||||
min_age=_interval(_text(domain, "minPwdAge")) or timedelta(0),
|
||||
max_age=_interval(_text(domain, "maxPwdAge")),
|
||||
)
|
||||
pso_dn = _text(entry[1], "msDS-ResultantPSO")
|
||||
if not pso_dn:
|
||||
return policy
|
||||
try:
|
||||
pso = self._search(conn, pso_dn, ldap.SCOPE_BASE, "(objectClass=*)", PSO_ATTRIBUTES)
|
||||
except ldap.INSUFFICIENT_ACCESS:
|
||||
return replace(policy, fine_grained=True, fine_grained_readable=False)
|
||||
attrs = pso[0][1]
|
||||
return PasswordPolicy(
|
||||
min_length=int(_text(attrs, "msDS-MinimumPasswordLength") or 0),
|
||||
complexity=_text(attrs, "msDS-PasswordComplexityEnabled").upper() == "TRUE",
|
||||
history_length=int(_text(attrs, "msDS-PasswordHistoryLength") or 0),
|
||||
min_age=_interval(_text(attrs, "msDS-MinimumPasswordAge")) or timedelta(0),
|
||||
max_age=_interval(_text(attrs, "msDS-MaximumPasswordAge")),
|
||||
fine_grained=True,
|
||||
)
|
||||
|
||||
def update_attributes(self, sam: str, changes: dict[str, str | None]) -> None:
|
||||
ldap = self._ldap
|
||||
refused = [name for name in changes if name not in WRITABLE_ATTRIBUTES]
|
||||
if refused:
|
||||
raise Forbidden(f"Attributes are not writable: {', '.join(sorted(refused))}")
|
||||
mods = []
|
||||
for name, value in changes.items():
|
||||
if value is not None and (not isinstance(value, str) or "\x00" in value):
|
||||
raise ValidationFailed(f"Invalid value for {name}")
|
||||
values = [value.encode("utf-8")] if value else []
|
||||
mods.append((ldap.MOD_REPLACE, name, values))
|
||||
if not mods:
|
||||
return
|
||||
with self._service() as conn:
|
||||
dn = self._require_entry(conn, sam)[0]
|
||||
try:
|
||||
conn.modify_s(dn, mods)
|
||||
except (ldap.CONSTRAINT_VIOLATION, ldap.INVALID_SYNTAX) as exc:
|
||||
raise ValidationFailed("The directory refused a value") from exc
|
||||
|
||||
def change_password(self, sam: str, current: str, new: str) -> None:
|
||||
ldap = self._ldap
|
||||
if not current or not new:
|
||||
raise PasswordRejected("wrong_current" if not current else "policy")
|
||||
old_value = f'"{current}"'.encode("utf-16-le")
|
||||
new_value = f'"{new}"'.encode("utf-16-le")
|
||||
mods = [
|
||||
(ldap.MOD_DELETE, "unicodePwd", [old_value]),
|
||||
(ldap.MOD_ADD, "unicodePwd", [new_value]),
|
||||
]
|
||||
with self._service() as conn:
|
||||
dn = self._require_entry(conn, sam)[0]
|
||||
try:
|
||||
conn.modify_s(dn, mods)
|
||||
except (ldap.CONSTRAINT_VIOLATION, ldap.UNWILLING_TO_PERFORM) as exc:
|
||||
raise PasswordRejected(self._password_reason(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _password_reason(exc: Exception) -> str:
|
||||
detail = exc.args[0] if exc.args and isinstance(exc.args[0], dict) else {}
|
||||
info = str(detail.get("info", ""))
|
||||
match = _AD_ERROR_CODE.search(info)
|
||||
if match and match.group(1).upper() == _WRONG_CURRENT_CODE:
|
||||
return "wrong_current"
|
||||
return "policy"
|
||||
Reference in New Issue
Block a user