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 ADDED
@@ -0,0 +1,14 @@
1
+ import uvicorn
2
+
3
+ from . import settings
4
+
5
+ __version__ = "0.9.0"
6
+
7
+
8
+ def run():
9
+ uvicorn.run(
10
+ "ldap_ui.app:app",
11
+ log_level="info",
12
+ host="127.0.0.1" if settings.DEBUG else None,
13
+ port=5000,
14
+ )
ldap_ui/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ldap_ui import run
2
+
3
+ if __name__ == "__main__":
4
+ run()
ldap_ui/app.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ Simplistic ReST proxy for LDAP access.
3
+
4
+ Authentication is either hard-wired in the settings,
5
+ or else only HTTP basic auth is supported.
6
+
7
+ The backend is stateless, it re-connects to the directory on every request.
8
+ No sessions, no cookies, nothing else.
9
+ """
10
+
11
+ import base64
12
+ import binascii
13
+ import contextlib
14
+ from typing import AsyncGenerator
15
+
16
+ import ldap
17
+ import uvicorn
18
+ from ldap.schema import SubSchema
19
+ from starlette.applications import Starlette
20
+ from starlette.authentication import (
21
+ AuthCredentials,
22
+ AuthenticationBackend,
23
+ AuthenticationError,
24
+ SimpleUser,
25
+ )
26
+ from starlette.exceptions import HTTPException
27
+ from starlette.middleware import Middleware
28
+ from starlette.middleware.authentication import AuthenticationMiddleware
29
+ from starlette.middleware.base import BaseHTTPMiddleware
30
+ from starlette.middleware.gzip import GZipMiddleware
31
+ from starlette.requests import HTTPConnection, Request
32
+ from starlette.responses import PlainTextResponse, Response
33
+ from starlette.routing import Mount
34
+ from starlette.staticfiles import StaticFiles
35
+
36
+ from . import settings
37
+ from .ldap_api import api
38
+ from .ldap_helpers import WITH_OPERATIONAL_ATTRS, empty, ldap_connect, unique
39
+
40
+ # Force authentication
41
+ UNAUTHORIZED = Response(
42
+ "Invalid credentials",
43
+ status_code=401,
44
+ headers={"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'},
45
+ )
46
+
47
+
48
+ class LdapConnectionMiddleware(BaseHTTPMiddleware):
49
+ async def dispatch(
50
+ self, request: Request, call_next: AsyncGenerator[Request, Response]
51
+ ) -> Response:
52
+ "Add an authenticated LDAP connection to the request"
53
+
54
+ # Short-circuit static files
55
+ if not request.url.path.startswith("/api"):
56
+ return await call_next(request)
57
+
58
+ try:
59
+ with ldap_connect() as connection:
60
+ dn, password = None, None
61
+
62
+ # Search for basic auth user
63
+ if type(request.user) is LdapUser:
64
+ dn, _attrs = await unique(
65
+ connection,
66
+ connection.search(
67
+ settings.BASE_DN,
68
+ ldap.SCOPE_SUBTREE,
69
+ settings.GET_BIND_DN_FILTER(request.user.username),
70
+ ),
71
+ )
72
+ password = request.user.password
73
+
74
+ # Hard-wired credentials
75
+ if dn is None:
76
+ dn = settings.GET_BIND_DN(request.user.display_name)
77
+ password = settings.GET_BIND_PASSWORD()
78
+
79
+ if dn is None:
80
+ return UNAUTHORIZED
81
+
82
+ # Log in
83
+ await empty(connection, connection.simple_bind(dn, password))
84
+ request.state.ldap = connection
85
+ return await call_next(request)
86
+
87
+ except ldap.INVALID_CREDENTIALS:
88
+ return UNAUTHORIZED
89
+
90
+ except ldap.LDAPError as err:
91
+ return PlainTextResponse(
92
+ ldap_exception_message(err),
93
+ status_code=500,
94
+ )
95
+
96
+
97
+ def ldap_exception_message(exc: ldap.LDAPError) -> str:
98
+ args = exc.args[0]
99
+ if "info" in args:
100
+ return args.get("info", "") + ": " + args.get("desc", "")
101
+ return args.get("desc", "")
102
+
103
+
104
+ class LdapUser(SimpleUser):
105
+ "LDAP credentials"
106
+
107
+ def __init__(self, username: str, password: str):
108
+ super().__init__(username)
109
+ self.password = password
110
+
111
+
112
+ class BasicAuthBackend(AuthenticationBackend):
113
+ "Handle basic authentication"
114
+
115
+ async def authenticate(self, conn: HTTPConnection):
116
+ "Place LDAP credentials in request.user"
117
+
118
+ if "Authorization" in conn.headers:
119
+ try:
120
+ auth = conn.headers["Authorization"]
121
+ scheme, credentials = auth.split()
122
+ if scheme.lower() == "basic":
123
+ decoded = base64.b64decode(credentials).decode("ascii")
124
+ username, _, password = decoded.partition(":")
125
+ return (
126
+ AuthCredentials(["authenticated"]),
127
+ LdapUser(username, password),
128
+ )
129
+ except (ValueError, UnicodeDecodeError, binascii.Error) as _exc:
130
+ raise AuthenticationError("Invalid basic auth credentials")
131
+
132
+
133
+ class CacheBustingMiddleware(BaseHTTPMiddleware):
134
+ "Forbid caching of API responses"
135
+
136
+ async def dispatch(
137
+ self, request: Request, call_next: AsyncGenerator[Request, Response]
138
+ ) -> Response:
139
+ response = await call_next(request)
140
+ if request.url.path.startswith("/api"):
141
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
142
+ response.headers["Pragma"] = "no-cache"
143
+ response.headers["Expires"] = "0"
144
+ return response
145
+
146
+
147
+ async def http_exception(_request: Request, exc: HTTPException):
148
+ "Send error responses"
149
+ assert exc.status_code >= 400
150
+ return PlainTextResponse(
151
+ exc.detail,
152
+ status_code=exc.status_code,
153
+ headers=exc.headers,
154
+ )
155
+
156
+
157
+ async def forbidden(_request: Request, exc: ldap.LDAPError):
158
+ "HTTP 403 Forbidden"
159
+ return PlainTextResponse(ldap_exception_message(exc), status_code=403)
160
+
161
+
162
+ @contextlib.asynccontextmanager
163
+ async def lifespan(app):
164
+ with ldap_connect() as connection:
165
+ # See: https://hub.packtpub.com/python-ldap-applications-part-4-ldap-schema/
166
+ _dn, sub_schema = await unique(
167
+ connection,
168
+ connection.search(
169
+ settings.SCHEMA_DN,
170
+ ldap.SCOPE_BASE,
171
+ attrlist=WITH_OPERATIONAL_ATTRS,
172
+ ),
173
+ )
174
+ app.state.schema = SubSchema(sub_schema, check_uniqueness=2)
175
+ yield
176
+
177
+
178
+ # Main ASGI entry
179
+ app = Starlette(
180
+ debug=settings.DEBUG,
181
+ exception_handlers={
182
+ HTTPException: http_exception,
183
+ ldap.INSUFFICIENT_ACCESS: forbidden,
184
+ },
185
+ lifespan=lifespan,
186
+ middleware=(
187
+ Middleware(AuthenticationMiddleware, backend=BasicAuthBackend()),
188
+ Middleware(LdapConnectionMiddleware),
189
+ Middleware(CacheBustingMiddleware),
190
+ Middleware(GZipMiddleware, minimum_size=512, compresslevel=6),
191
+ ),
192
+ routes=[
193
+ Mount("/api", routes=api.routes),
194
+ Mount("/", StaticFiles(packages=["ldap_ui"], html=True)),
195
+ ],
196
+ )
ldap_ui/ldap_api.py ADDED
@@ -0,0 +1,416 @@
1
+ """
2
+ ReST endpoints for LDAP access.
3
+
4
+ Directory operations are accessible to the frontend
5
+ through a hand-knit API, responses are usually converted to JSON.
6
+
7
+ Asynchronous LDAP operations are used as much as possible.
8
+ """
9
+
10
+ import base64
11
+ import io
12
+ from typing import Any, Optional, Tuple
13
+
14
+ import ldap
15
+ import ldif
16
+ from ldap.ldapobject import LDAPObject
17
+ from ldap.modlist import addModlist, modifyModlist
18
+ from ldap.schema import SubSchema
19
+ from ldap.schema.models import AttributeType, LDAPSyntax, ObjectClass
20
+ from starlette.datastructures import UploadFile
21
+ from starlette.exceptions import HTTPException
22
+ from starlette.requests import Request
23
+ from starlette.responses import JSONResponse, PlainTextResponse, Response
24
+ from starlette.routing import Router
25
+
26
+ from . import settings
27
+ from .ldap_helpers import (
28
+ WITH_OPERATIONAL_ATTRS,
29
+ empty,
30
+ get_entry_by_dn,
31
+ ldap_connect,
32
+ result,
33
+ )
34
+ from .schema import frontend_schema
35
+
36
+ __all__ = ("api",)
37
+
38
+
39
+ NO_CONTENT = Response(status_code=204)
40
+
41
+ # Special fields
42
+ PHOTOS = ("jpegPhoto", "thumbnailPhoto")
43
+ PASSWORDS = ("userPassword",)
44
+
45
+ # Special syntaxes
46
+ OCTET_STRING = "1.3.6.1.4.1.1466.115.121.1.40"
47
+ INTEGER = "1.3.6.1.4.1.1466.115.121.1.27"
48
+
49
+ # Starlette router to decorate endpoints
50
+ api = Router()
51
+
52
+
53
+ @api.route("/whoami")
54
+ async def whoami(request: Request) -> JSONResponse:
55
+ "DN of the current user"
56
+ return JSONResponse(request.state.ldap.whoami_s().replace("dn:", ""))
57
+
58
+
59
+ @api.route("/tree/{basedn}")
60
+ async def tree(request: Request) -> JSONResponse:
61
+ "List directory entries"
62
+
63
+ basedn = request.path_params["basedn"]
64
+ scope = ldap.SCOPE_ONELEVEL
65
+ if basedn == "base":
66
+ scope = ldap.SCOPE_BASE
67
+ basedn = settings.BASE_DN
68
+
69
+ return JSONResponse(await _tree(request, basedn, scope))
70
+
71
+
72
+ async def _tree(request: Request, basedn: str, scope: int) -> list[dict[str, Any]]:
73
+ "Get all nodes below a DN (including the DN) within the given scope"
74
+
75
+ connection = request.state.ldap
76
+ return [
77
+ {
78
+ "dn": dn,
79
+ "structuralObjectClass": attrs["structuralObjectClass"][0].decode(),
80
+ "hasSubordinates": b"TRUE" == attrs["hasSubordinates"][0],
81
+ }
82
+ async for dn, attrs in result(
83
+ connection,
84
+ connection.search(basedn, scope, attrlist=WITH_OPERATIONAL_ATTRS),
85
+ )
86
+ ]
87
+
88
+
89
+ def _entry(schema: SubSchema, res: Tuple[str, Any]) -> dict[str, Any]:
90
+ "Prepare an LDAP entry for transmission"
91
+
92
+ dn, attrs = res
93
+ ocs = set([oc.decode() for oc in attrs["objectClass"]])
94
+ must_attrs, _may_attrs = schema.attribute_types(ocs)
95
+ soc = [
96
+ oc.names[0]
97
+ for oc in map(lambda o: schema.get_obj(ObjectClass, o), ocs)
98
+ if oc.kind == 0
99
+ ]
100
+ aux = set(
101
+ schema.get_obj(ObjectClass, a).names[0]
102
+ for a in schema.get_applicable_aux_classes(soc[0])
103
+ )
104
+
105
+ # 23 suppress userPassword
106
+ if "userPassword" in attrs:
107
+ attrs["userPassword"] = [b"*****"]
108
+
109
+ # Filter out binary attributes
110
+ binary = set()
111
+ for attr in attrs:
112
+ obj = schema.get_obj(AttributeType, attr)
113
+
114
+ # Octet strings are not used consistently.
115
+ # Try to decode as text and treat as binary on failure
116
+ if not obj.syntax or obj.syntax == OCTET_STRING:
117
+ try:
118
+ for val in attrs[attr]:
119
+ assert val.decode().isprintable()
120
+ except: # noqa: E722
121
+ binary.add(attr)
122
+
123
+ else: # Check human-readable flag in schema
124
+ syntax = schema.get_obj(LDAPSyntax, obj.syntax)
125
+ if syntax.not_human_readable:
126
+ binary.add(attr)
127
+
128
+ return {
129
+ "attrs": {
130
+ k: [
131
+ base64.b64encode(val).decode() if k in binary else val.decode()
132
+ for val in values
133
+ ]
134
+ for k, values in attrs.items()
135
+ },
136
+ "meta": {
137
+ "dn": dn,
138
+ "required": [schema.get_obj(AttributeType, a).names[0] for a in must_attrs],
139
+ "aux": sorted(aux - ocs),
140
+ "binary": sorted(binary),
141
+ "hints": {}, # FIXME obsolete?
142
+ "autoFilled": [],
143
+ },
144
+ }
145
+
146
+
147
+ @api.route("/entry/{dn}", methods=("GET", "POST", "DELETE", "PUT"))
148
+ async def entry(request: Request) -> Response:
149
+ "Edit directory entries"
150
+
151
+ dn = request.path_params["dn"]
152
+ connection = request.state.ldap
153
+
154
+ if request.method == "GET":
155
+ return JSONResponse(
156
+ _entry(request.app.state.schema, await get_entry_by_dn(connection, dn))
157
+ )
158
+
159
+ if request.method == "DELETE":
160
+ for entry in reversed(
161
+ sorted(await _tree(request, dn, ldap.SCOPE_SUBTREE), key=_dn_order)
162
+ ):
163
+ await empty(connection, connection.delete(entry["dn"]))
164
+ return NO_CONTENT
165
+
166
+ # Copy JSON payload into a dictionary of non-empty byte strings
167
+ json = await request.json()
168
+ req = {
169
+ k: [s.encode() for s in filter(None, v)]
170
+ for k, v in json.items()
171
+ if k not in PHOTOS and (k not in PASSWORDS or request.method == "PUT")
172
+ }
173
+
174
+ if request.method == "POST":
175
+ # Get previous values from directory
176
+ res = await get_entry_by_dn(connection, dn)
177
+ mods = {k: v for k, v in res[1].items() if k in req}
178
+ modlist = modifyModlist(mods, req)
179
+
180
+ if modlist: # Apply changes and send changed keys back
181
+ await empty(connection, connection.modify(dn, modlist))
182
+ return JSONResponse({"changed": sorted(set(m[1] for m in modlist))})
183
+
184
+ if request.method == "PUT":
185
+ # Create new object
186
+ modlist = addModlist(req)
187
+ if modlist:
188
+ await empty(connection, connection.add(dn, modlist))
189
+ return JSONResponse({"changed": ["dn"]}) # Dummy
190
+
191
+
192
+ @api.route("/blob/{attr}/{index:int}/{dn}", methods=("GET", "DELETE", "PUT"))
193
+ async def blob(request: Request) -> Response:
194
+ "Handle binary attributes"
195
+
196
+ attr = request.path_params["attr"]
197
+ index = request.path_params["index"]
198
+ dn = request.path_params["dn"]
199
+ connection = request.state.ldap
200
+
201
+ _dn, attrs = await get_entry_by_dn(connection, dn)
202
+
203
+ if request.method == "GET":
204
+ if attr not in attrs or len(attrs[attr]) <= index:
205
+ raise HTTPException(404, f"Attribute {attr} not found for DN {dn}")
206
+
207
+ return Response(
208
+ attrs[attr][index],
209
+ media_type="application/octet-stream",
210
+ headers={
211
+ "Content-Disposition": f'attachment; filename="{attr}-{index:d}.bin"'
212
+ },
213
+ )
214
+
215
+ if request.method == "PUT":
216
+ async with request.form() as form_data:
217
+ blob = form_data["blob"]
218
+ if type(blob) is UploadFile:
219
+ data = await blob.read(blob.size)
220
+ if attr in attrs:
221
+ await empty(
222
+ connection,
223
+ connection.modify(
224
+ dn, [(1, attr, None), (0, attr, data + attrs[attr])]
225
+ ),
226
+ )
227
+ else:
228
+ await empty(connection, connection.modify(dn, [(0, attr, data)]))
229
+ return NO_CONTENT
230
+
231
+ if request.method == "DELETE":
232
+ if attr not in attrs or len(attrs[attr]) <= index:
233
+ raise HTTPException(404, f"Attribute {attr} not found for DN {dn}")
234
+ await empty(connection, connection.modify(dn, [(1, attr, None)]))
235
+ data = attrs[attr][:index] + attrs[attr][index + 1 :]
236
+ if data:
237
+ await empty(connection, connection.modify(dn, [(0, attr, data)]))
238
+ return NO_CONTENT
239
+
240
+
241
+ @api.route("/ldif/{dn}")
242
+ async def ldifDump(request: Request) -> PlainTextResponse:
243
+ "Dump an entry as LDIF"
244
+
245
+ dn = request.path_params["dn"]
246
+ out = io.StringIO()
247
+ writer = ldif.LDIFWriter(out)
248
+ connection = request.state.ldap
249
+
250
+ async for dn, attrs in result(
251
+ connection, connection.search(dn, ldap.SCOPE_SUBTREE)
252
+ ):
253
+ writer.unparse(dn, attrs)
254
+
255
+ file_name = dn.split(",")[0].split("=")[1]
256
+ return PlainTextResponse(
257
+ out.getvalue(),
258
+ headers={"Content-Disposition": f'attachment; filename="{file_name}.ldif"'},
259
+ )
260
+
261
+
262
+ class LDIFReader(ldif.LDIFParser):
263
+ def __init__(self, input: str, con: LDAPObject):
264
+ ldif.LDIFParser.__init__(self, io.BytesIO(input))
265
+ self.count = 0
266
+ self.con = con
267
+
268
+ def handle(self, dn: str, entry: dict[str, Any]):
269
+ self.con.add_s(dn, addModlist(entry))
270
+ self.count += 1
271
+
272
+
273
+ @api.route("/ldif", methods=("POST",))
274
+ async def ldifUpload(
275
+ request: Request,
276
+ ) -> Response:
277
+ "Import LDIF"
278
+
279
+ reader = LDIFReader(await request.body(), request.state.ldap)
280
+ reader.parse()
281
+ return NO_CONTENT
282
+
283
+
284
+ @api.route("/rename/{dn}", methods=("POST",))
285
+ async def rename(request: Request) -> JSONResponse:
286
+ "Rename an entry"
287
+
288
+ dn = request.path_params["dn"]
289
+ rdn = await request.json()
290
+ connection = request.state.ldap
291
+ await empty(connection, connection.rename(dn, rdn, delold=0))
292
+ return NO_CONTENT
293
+
294
+
295
+ def _cn(entry: dict) -> Optional[str]:
296
+ "Try to extract a CN"
297
+ if "cn" in entry and entry["cn"]:
298
+ return entry["cn"][0].decode()
299
+
300
+
301
+ @api.route("/entry/password/{dn}", methods=("POST",))
302
+ async def passwd(request: Request) -> JSONResponse:
303
+ "Update passwords"
304
+
305
+ dn = request.path_params["dn"]
306
+ args = await request.json()
307
+
308
+ if "check" in args:
309
+ with ldap_connect() as con:
310
+ try:
311
+ con.simple_bind_s(dn, args["check"])
312
+ return JSONResponse(True)
313
+ except ldap.INVALID_CREDENTIALS:
314
+ return JSONResponse(False)
315
+
316
+ if "old" in args and "new1" in args:
317
+ connection = request.state.ldap
318
+ if args["new1"]:
319
+ await empty(
320
+ connection,
321
+ connection.passwd(dn, args.get("old") or None, args["new1"]),
322
+ )
323
+ _dn, attrs = await get_entry_by_dn(connection, dn)
324
+ return JSONResponse(attrs["userPassword"][0].decode())
325
+
326
+ else:
327
+ await empty(connection, connection.modify(dn, [(1, "userPassword", None)]))
328
+ return JSONResponse(None)
329
+
330
+
331
+ @api.route("/search/{query:path}")
332
+ async def search(request: Request) -> JSONResponse:
333
+ "Search the directory"
334
+
335
+ query = request.path_params["query"]
336
+ if len(query) < settings.SEARCH_QUERY_MIN:
337
+ return JSONResponse([])
338
+
339
+ if "=" in query: # Search specific attributes
340
+ if "(" not in query:
341
+ query = f"({query})"
342
+ else: # Build default query
343
+ query = "(|%s)" % "".join(p % query for p in settings.SEARCH_PATTERNS)
344
+
345
+ # Collect results
346
+ res = []
347
+ connection = request.state.ldap
348
+ async for dn, attrs in result(
349
+ connection, connection.search(settings.BASE_DN, ldap.SCOPE_SUBTREE, query)
350
+ ):
351
+ res.append({"dn": dn, "name": _cn(attrs) or dn})
352
+ if len(res) >= settings.SEARCH_MAX:
353
+ break
354
+ return JSONResponse(res)
355
+
356
+
357
+ def _dn_order(node):
358
+ "Reverse DN parts for tree ordering"
359
+ return tuple(reversed(node["dn"].lower().split(",")))
360
+
361
+
362
+ @api.route("/subtree/{dn}")
363
+ async def subtree(request: Request) -> JSONResponse:
364
+ "List the subtree below a DN"
365
+
366
+ dn = request.path_params["dn"]
367
+ result, start = [], len(dn.split(","))
368
+ for node in sorted(await _tree(request, dn, ldap.SCOPE_SUBTREE), key=_dn_order):
369
+ if node["dn"] == dn:
370
+ continue
371
+ node["level"] = len(node["dn"].split(",")) - start
372
+ result.append(node)
373
+ return JSONResponse(result)
374
+
375
+
376
+ @api.route("/range/{attribute}")
377
+ async def attribute_range(request: Request) -> JSONResponse:
378
+ "List all values for a numeric attribute of an objectClass like uidNumber or gidNumber"
379
+
380
+ attribute = request.path_params["attribute"]
381
+ connection = request.state.ldap
382
+ obj = request.app.state.schema.get_obj(AttributeType, attribute)
383
+
384
+ values = set(
385
+ [
386
+ int(attrs[attribute][0])
387
+ async for dn, attrs in result(
388
+ connection,
389
+ connection.search(
390
+ settings.BASE_DN,
391
+ ldap.SCOPE_SUBTREE,
392
+ f"({attribute}=*)",
393
+ attrlist=(attribute,),
394
+ ),
395
+ )
396
+ if obj and obj.syntax == INTEGER
397
+ ]
398
+ )
399
+
400
+ if not values:
401
+ raise HTTPException(404, f"No values found for attribute {attribute}")
402
+
403
+ minimum, maximum = min(values), max(values)
404
+ return JSONResponse(
405
+ {
406
+ "min": minimum,
407
+ "max": maximum,
408
+ "next": min(set(range(minimum, maximum + 2)) - values),
409
+ }
410
+ )
411
+
412
+
413
+ @api.route("/schema")
414
+ async def json_schema(request: Request) -> JSONResponse:
415
+ "Dump the LDAP schema as JSON"
416
+ return JSONResponse(frontend_schema(request.app.state.schema))