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/__init__.py
ADDED
ldap_ui/__main__.py
ADDED
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))
|