ldap-ui 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ldap_ui/__init__.py +14 -0
- ldap_ui/__main__.py +4 -0
- ldap_ui/app.py +196 -0
- ldap_ui/ldap_api.py +416 -0
- ldap_ui/ldap_helpers.py +112 -0
- ldap_ui/schema.py +130 -0
- ldap_ui/settings.py +85 -0
- ldap_ui/statics/assets/fontawesome-webfont-B-jkhYfk.woff2 +0 -0
- ldap_ui/statics/assets/fontawesome-webfont-CDK5bt4p.woff +0 -0
- ldap_ui/statics/assets/fontawesome-webfont-CQDK8MU3.ttf +0 -0
- ldap_ui/statics/assets/fontawesome-webfont-D13rzr4g.svg +2671 -0
- ldap_ui/statics/assets/fontawesome-webfont-G5YE5S7X.eot +0 -0
- ldap_ui/statics/assets/index-CA45Sb-q.js +18 -0
- ldap_ui/statics/assets/index-CA45Sb-q.js.gz +0 -0
- ldap_ui/statics/assets/index-DlTKbnmq.css +4 -0
- ldap_ui/statics/assets/index-DlTKbnmq.css.gz +0 -0
- ldap_ui/statics/favicon.ico +0 -0
- ldap_ui/statics/index.html +24 -0
- ldap_ui-0.9.0.dist-info/LICENSE.txt +7 -0
- ldap_ui-0.9.0.dist-info/METADATA +142 -0
- ldap_ui-0.9.0.dist-info/RECORD +24 -0
- ldap_ui-0.9.0.dist-info/WHEEL +5 -0
- ldap_ui-0.9.0.dist-info/entry_points.txt +2 -0
- ldap_ui-0.9.0.dist-info/top_level.txt +1 -0
ldap_ui/ldap_helpers.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for asynchronous LDAP operations.
|
|
3
|
+
|
|
4
|
+
HTTP endpoints typically trigger asynchronous LDAP requests
|
|
5
|
+
which return an ID for the operation being performed.
|
|
6
|
+
Results are then gathered in non-blocking mode.
|
|
7
|
+
|
|
8
|
+
Some shorthands are provided for common usages
|
|
9
|
+
like retrieving a unique result or waiting for an
|
|
10
|
+
operation to complete without results.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
from typing import AsyncGenerator, Generator, Tuple
|
|
15
|
+
|
|
16
|
+
import ldap
|
|
17
|
+
from anyio import sleep
|
|
18
|
+
from ldap.ldapobject import LDAPObject
|
|
19
|
+
from starlette.exceptions import HTTPException
|
|
20
|
+
|
|
21
|
+
from . import settings
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
"empty",
|
|
25
|
+
"get_entry_by_dn",
|
|
26
|
+
"ldap_connect",
|
|
27
|
+
"result",
|
|
28
|
+
"unique",
|
|
29
|
+
"WITH_OPERATIONAL_ATTRS",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Constant to add technical attributes in LDAP search results
|
|
34
|
+
WITH_OPERATIONAL_ATTRS = ("*", "+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextlib.contextmanager
|
|
38
|
+
def ldap_connect() -> Generator[LDAPObject, None, None]:
|
|
39
|
+
"Open an LDAP connection"
|
|
40
|
+
|
|
41
|
+
url = settings.LDAP_URL
|
|
42
|
+
connection = ldap.initialize(url)
|
|
43
|
+
|
|
44
|
+
# #43 TLS, see https://stackoverflow.com/a/8795694
|
|
45
|
+
if settings.USE_TLS or settings.INSECURE_TLS:
|
|
46
|
+
cert_level = (
|
|
47
|
+
ldap.OPT_X_TLS_NEVER if settings.INSECURE_TLS else ldap.OPT_X_TLS_DEMAND
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, cert_level)
|
|
51
|
+
# See https://stackoverflow.com/a/38136255
|
|
52
|
+
connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
|
|
53
|
+
if not url.startswith("ldaps://"):
|
|
54
|
+
connection.start_tls_s()
|
|
55
|
+
yield connection
|
|
56
|
+
connection.unbind_s()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def result(
|
|
60
|
+
connection: LDAPObject, msgid: int
|
|
61
|
+
) -> AsyncGenerator[Tuple[str, dict[str, list[bytes]]], None]:
|
|
62
|
+
"Stream LDAP result entries without blocking other tasks"
|
|
63
|
+
|
|
64
|
+
while True:
|
|
65
|
+
r_type, r_data = connection.result(msgid=msgid, all=0, timeout=0)
|
|
66
|
+
if r_type is None: # Throttle to 100 results / second
|
|
67
|
+
await sleep(0.01)
|
|
68
|
+
elif r_data == []: # Operation completed
|
|
69
|
+
break
|
|
70
|
+
else:
|
|
71
|
+
yield r_data[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def unique(
|
|
75
|
+
connection: LDAPObject,
|
|
76
|
+
msgid: int,
|
|
77
|
+
) -> Tuple[str, dict[str, list[bytes]]]:
|
|
78
|
+
"Asynchronously collect a unique result"
|
|
79
|
+
|
|
80
|
+
res = None
|
|
81
|
+
async for r in result(connection, msgid):
|
|
82
|
+
if res is None:
|
|
83
|
+
res = r
|
|
84
|
+
else:
|
|
85
|
+
connection.abandon(msgid)
|
|
86
|
+
raise HTTPException(500, "Non-unique result")
|
|
87
|
+
if res is None:
|
|
88
|
+
raise HTTPException(404, "Empty search result")
|
|
89
|
+
return res
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def empty(
|
|
93
|
+
connection: LDAPObject,
|
|
94
|
+
msgid: int,
|
|
95
|
+
) -> None:
|
|
96
|
+
"Asynchronously wait for an empty result"
|
|
97
|
+
|
|
98
|
+
async for r in result(connection, msgid):
|
|
99
|
+
connection.abandon(msgid)
|
|
100
|
+
raise HTTPException(500, "Unexpected result")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def get_entry_by_dn(
|
|
104
|
+
connection: LDAPObject,
|
|
105
|
+
dn: str,
|
|
106
|
+
) -> Tuple[str, dict[str, list[bytes]]]:
|
|
107
|
+
"Asynchronously retrieve an LDAP entry by its DN"
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return await unique(connection, connection.search(dn, ldap.SCOPE_BASE))
|
|
111
|
+
except ldap.NO_SUCH_OBJECT:
|
|
112
|
+
raise HTTPException(404, f"DN not found: {dn}")
|
ldap_ui/schema.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON-serializable representation of the LDAP directory schema.
|
|
3
|
+
|
|
4
|
+
It is used by the frontend for consistency checks, and
|
|
5
|
+
to determine how individual attributes should be presented
|
|
6
|
+
to the user.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Generator
|
|
10
|
+
|
|
11
|
+
from ldap.schema import SubSchema
|
|
12
|
+
from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
|
|
13
|
+
|
|
14
|
+
__all__ = ("frontend_schema",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Object class constants
|
|
18
|
+
SCHEMA_OC_KIND = {
|
|
19
|
+
0: "structural",
|
|
20
|
+
1: "abstract",
|
|
21
|
+
2: "auxiliary",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Attribute usage constants
|
|
25
|
+
SCHEMA_ATTR_USAGE = {
|
|
26
|
+
0: "userApplications",
|
|
27
|
+
1: "directoryOperation",
|
|
28
|
+
2: "distributedOperation",
|
|
29
|
+
3: "dSAOperation",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def element(obj) -> dict:
|
|
34
|
+
"Basic information about an schema element"
|
|
35
|
+
name = obj.names[0]
|
|
36
|
+
return {
|
|
37
|
+
"oid": obj.oid,
|
|
38
|
+
"name": name[:1].lower() + name[1:],
|
|
39
|
+
"names": obj.names,
|
|
40
|
+
"desc": obj.desc,
|
|
41
|
+
"obsolete": bool(obj.obsolete),
|
|
42
|
+
"sup": sorted(obj.sup),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def object_class_dict(obj) -> dict:
|
|
47
|
+
"Additional information about an object class"
|
|
48
|
+
r = element(obj)
|
|
49
|
+
r.update(
|
|
50
|
+
{
|
|
51
|
+
"may": sorted(obj.may),
|
|
52
|
+
"must": sorted(obj.must),
|
|
53
|
+
"kind": SCHEMA_OC_KIND[obj.kind],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return r
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def attribute_dict(obj) -> dict:
|
|
60
|
+
"Additional information about an attribute"
|
|
61
|
+
r = element(obj)
|
|
62
|
+
r.update(
|
|
63
|
+
{
|
|
64
|
+
"single_value": bool(obj.single_value),
|
|
65
|
+
"no_user_mod": bool(obj.no_user_mod),
|
|
66
|
+
"usage": SCHEMA_ATTR_USAGE[obj.usage],
|
|
67
|
+
# FIXME avoid null values below
|
|
68
|
+
"equality": obj.equality,
|
|
69
|
+
"syntax": obj.syntax,
|
|
70
|
+
"substr": obj.substr,
|
|
71
|
+
"ordering": obj.ordering,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def syntax_dict(obj) -> dict:
|
|
78
|
+
"Information about an attribute syntax"
|
|
79
|
+
return {
|
|
80
|
+
"oid": obj.oid,
|
|
81
|
+
"desc": obj.desc,
|
|
82
|
+
"not_human_readable": bool(obj.not_human_readable),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def lowercase_dict(attr: str, items) -> dict:
|
|
87
|
+
"Create an dictionary with lowercased keys extracted from a given attribute"
|
|
88
|
+
return {obj[attr].lower(): obj for obj in items}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extract_type(
|
|
92
|
+
sub_schema: SubSchema, schema_class: Any
|
|
93
|
+
) -> Generator[Any, None, None]:
|
|
94
|
+
"Get non-obsolete objects from the schema for a type"
|
|
95
|
+
|
|
96
|
+
for oid in sub_schema.listall(schema_class):
|
|
97
|
+
obj = sub_schema.get_obj(schema_class, oid)
|
|
98
|
+
if schema_class is LDAPSyntax or not obj.obsolete:
|
|
99
|
+
yield obj
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# See: https://www.python-ldap.org/en/latest/reference/ldap-schema.html
|
|
103
|
+
def frontend_schema(sub_schema: SubSchema) -> dict[Any]:
|
|
104
|
+
"Dump an LDAP SubSchema"
|
|
105
|
+
|
|
106
|
+
return dict(
|
|
107
|
+
attributes=lowercase_dict(
|
|
108
|
+
"name",
|
|
109
|
+
sorted(
|
|
110
|
+
map(
|
|
111
|
+
attribute_dict,
|
|
112
|
+
extract_type(sub_schema, AttributeType),
|
|
113
|
+
),
|
|
114
|
+
key=lambda x: x["name"],
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
objectClasses=lowercase_dict(
|
|
118
|
+
"name",
|
|
119
|
+
sorted(
|
|
120
|
+
map(
|
|
121
|
+
object_class_dict,
|
|
122
|
+
extract_type(sub_schema, ObjectClass),
|
|
123
|
+
),
|
|
124
|
+
key=lambda x: x["name"],
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
syntaxes=lowercase_dict(
|
|
128
|
+
"oid", map(syntax_dict, extract_type(sub_schema, LDAPSyntax))
|
|
129
|
+
),
|
|
130
|
+
)
|
ldap_ui/settings.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from starlette.config import Config
|
|
5
|
+
|
|
6
|
+
config = Config(".env")
|
|
7
|
+
|
|
8
|
+
# App settings
|
|
9
|
+
DEBUG = config("DEBUG", cast=lambda x: bool(x), default=False)
|
|
10
|
+
PREFERRED_URL_SCHEME = "https"
|
|
11
|
+
SECRET_KEY = os.urandom(16)
|
|
12
|
+
|
|
13
|
+
#
|
|
14
|
+
# LDAP settings
|
|
15
|
+
#
|
|
16
|
+
LDAP_URL = config("LDAP_URL", default="ldap:///")
|
|
17
|
+
BASE_DN = config("BASE_DN") # Always required
|
|
18
|
+
|
|
19
|
+
USE_TLS = config(
|
|
20
|
+
"USE_TLS",
|
|
21
|
+
cast=lambda x: bool(x),
|
|
22
|
+
default=LDAP_URL.startswith("ldaps://"),
|
|
23
|
+
)
|
|
24
|
+
INSECURE_TLS = config("INSECURE_TLS", cast=lambda x: bool(x), default=False)
|
|
25
|
+
|
|
26
|
+
SCHEMA_DN = config("SCHEMA_DN", default="cn=subschema")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#
|
|
30
|
+
# Binding
|
|
31
|
+
#
|
|
32
|
+
def GET_BIND_DN(username) -> Optional[str]:
|
|
33
|
+
"Try to determine the login DN from the environment and request"
|
|
34
|
+
|
|
35
|
+
# Use a hard-wired DN from the environment.
|
|
36
|
+
# If this is set and a GET_BIND_PASSWORD returns something,
|
|
37
|
+
# the UI will NOT ask for a login.
|
|
38
|
+
# You need to secure it otherwise!
|
|
39
|
+
if config("BIND_DN", default=None):
|
|
40
|
+
return config("BIND_DN")
|
|
41
|
+
|
|
42
|
+
# Optional user DN pattern string for authentication,
|
|
43
|
+
# e.g. "uid=%s,ou=people,dc=example,dc=com".
|
|
44
|
+
# This can be used to authenticate with directories
|
|
45
|
+
# that do not allow anonymous users to search.
|
|
46
|
+
elif config("BIND_PATTERN", default=None) and username:
|
|
47
|
+
return config("BIND_PATTERN") % username
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def GET_BIND_DN_FILTER(username) -> str:
|
|
51
|
+
"Produce a LDAP search filter for the login DN"
|
|
52
|
+
return SEARCH_PATTERNS[0] % username
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def GET_BIND_PASSWORD() -> Optional[str]:
|
|
56
|
+
"Try to determine the login password from the environment or request"
|
|
57
|
+
|
|
58
|
+
pw = config("BIND_PASSWORD", default=None)
|
|
59
|
+
if pw is not None:
|
|
60
|
+
return pw
|
|
61
|
+
|
|
62
|
+
pw_file = config("BIND_PASSWORD_FILE", default=None)
|
|
63
|
+
if pw_file is not None:
|
|
64
|
+
with open(pw_file) as file:
|
|
65
|
+
return file.read().rstrip("\n")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#
|
|
69
|
+
# Search
|
|
70
|
+
#
|
|
71
|
+
|
|
72
|
+
# Attribute to search for user names
|
|
73
|
+
LOGIN_ATTR = config("LOGIN_ATTR", default="uid")
|
|
74
|
+
|
|
75
|
+
# Search users by a number of common attributes
|
|
76
|
+
SEARCH_PATTERNS = (
|
|
77
|
+
"(%s=%%s)" % LOGIN_ATTR,
|
|
78
|
+
"(cn=%s*)",
|
|
79
|
+
"(gn=%s*)",
|
|
80
|
+
"(sn=%s*)",
|
|
81
|
+
)
|
|
82
|
+
SEARCH_QUERY_MIN = config(
|
|
83
|
+
"SEARCH_QUERY_MIN", cast=int, default=2
|
|
84
|
+
) # Minimum length of query term
|
|
85
|
+
SEARCH_MAX = config("SEARCH_MAX", cast=int, default=50) # Maximum number of results
|
|
Binary file
|
|
Binary file
|
|
Binary file
|