ldap-ui 0.9.15__py3-none-any.whl → 0.10.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 +1 -1
- ldap_ui/app.py +84 -191
- ldap_ui/entities.py +62 -0
- ldap_ui/ldap_api.py +309 -276
- ldap_ui/ldap_helpers.py +51 -28
- ldap_ui/schema.py +8 -8
- ldap_ui/statics/assets/{index-qocMa2qY.css → index-4LadlPh6.css} +1 -1
- ldap_ui/statics/assets/index-4LadlPh6.css.gz +0 -0
- ldap_ui/statics/assets/index-CVDJWmXN.js +19 -0
- ldap_ui/statics/assets/index-CVDJWmXN.js.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/METADATA +8 -9
- ldap_ui-0.10.0.dist-info/RECORD +25 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/WHEEL +1 -1
- ldap_ui/statics/assets/index-CZWuB-hf.js +0 -18
- ldap_ui/statics/assets/index-CZWuB-hf.js.gz +0 -0
- ldap_ui/statics/assets/index-qocMa2qY.css.gz +0 -0
- ldap_ui-0.9.15.dist-info/RECORD +0 -24
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.0.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.10.0"
|
ldap_ui/app.py
CHANGED
|
@@ -8,60 +8,58 @@ The backend is stateless, it re-connects to the directory on every request.
|
|
|
8
8
|
No sessions, no cookies, nothing else.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import base64
|
|
12
|
-
import binascii
|
|
13
11
|
import logging
|
|
14
12
|
import sys
|
|
13
|
+
from contextlib import contextmanager
|
|
15
14
|
from http import HTTPStatus
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
AuthCredentials,
|
|
31
|
-
AuthenticationBackend,
|
|
32
|
-
AuthenticationError,
|
|
33
|
-
SimpleUser,
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, Request, Response
|
|
17
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from ldap import ( # type: ignore
|
|
21
|
+
ALREADY_EXISTS, # type: ignore
|
|
22
|
+
INSUFFICIENT_ACCESS, # type: ignore
|
|
23
|
+
INVALID_CREDENTIALS, # type: ignore
|
|
24
|
+
NO_SUCH_OBJECT, # type: ignore
|
|
25
|
+
OBJECT_CLASS_VIOLATION, # type: ignore
|
|
26
|
+
SCOPE_BASE, # type: ignore
|
|
27
|
+
UNWILLING_TO_PERFORM, # type: ignore
|
|
28
|
+
LDAPError, # type: ignore
|
|
34
29
|
)
|
|
35
|
-
|
|
36
|
-
from
|
|
37
|
-
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
38
|
-
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
39
|
-
from starlette.middleware.gzip import GZipMiddleware
|
|
40
|
-
from starlette.requests import HTTPConnection, Request
|
|
41
|
-
from starlette.responses import Response
|
|
42
|
-
from starlette.routing import Mount
|
|
43
|
-
from starlette.staticfiles import StaticFiles
|
|
44
|
-
|
|
45
|
-
from . import settings
|
|
30
|
+
|
|
31
|
+
from . import __version__, settings
|
|
46
32
|
from .ldap_api import api
|
|
47
|
-
from .ldap_helpers import WITH_OPERATIONAL_ATTRS,
|
|
33
|
+
from .ldap_helpers import WITH_OPERATIONAL_ATTRS, ldap_connect
|
|
48
34
|
|
|
49
35
|
LOG = logging.getLogger("ldap-ui")
|
|
50
36
|
|
|
37
|
+
LDAP_ERROR_TO_STATUS = {
|
|
38
|
+
ALREADY_EXISTS: HTTPStatus.CONFLICT,
|
|
39
|
+
INSUFFICIENT_ACCESS: HTTPStatus.FORBIDDEN,
|
|
40
|
+
INVALID_CREDENTIALS: HTTPStatus.UNAUTHORIZED,
|
|
41
|
+
NO_SUCH_OBJECT: HTTPStatus.NOT_FOUND,
|
|
42
|
+
OBJECT_CLASS_VIOLATION: HTTPStatus.BAD_REQUEST,
|
|
43
|
+
UNWILLING_TO_PERFORM: HTTPStatus.FORBIDDEN,
|
|
44
|
+
}
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
|
|
47
|
+
def format_ldap_error(exc: LDAPError) -> str:
|
|
48
|
+
cause = exc.args[0] if exc.args else {}
|
|
49
|
+
desc = cause.get("desc", "LDAP error").strip().capitalize()
|
|
50
|
+
if "info" in cause:
|
|
51
|
+
return f"{desc}: {cause['info'].strip().capitalize()}"
|
|
52
|
+
return f"{desc}"
|
|
57
53
|
|
|
58
54
|
|
|
59
55
|
if not settings.BASE_DN or not settings.SCHEMA_DN:
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
with
|
|
63
|
-
_dn, attrs = connection.search_s( #
|
|
64
|
-
"",
|
|
56
|
+
try: # Auto-detection from root DSE
|
|
57
|
+
connect = contextmanager(ldap_connect)
|
|
58
|
+
with connect() as connection:
|
|
59
|
+
_dn, attrs = connection.search_s( # type: ignore
|
|
60
|
+
"",
|
|
61
|
+
SCOPE_BASE,
|
|
62
|
+
attrlist=WITH_OPERATIONAL_ATTRS,
|
|
65
63
|
)[0]
|
|
66
64
|
base_dns = attrs.get("namingContexts", [])
|
|
67
65
|
if len(base_dns) == 1:
|
|
@@ -71,7 +69,7 @@ if not settings.BASE_DN or not settings.SCHEMA_DN:
|
|
|
71
69
|
schema_dns = attrs.get("subschemaSubentry", [])
|
|
72
70
|
settings.SCHEMA_DN = settings.SCHEMA_DN or schema_dns[0].decode()
|
|
73
71
|
except LDAPError as err:
|
|
74
|
-
LOG.error(
|
|
72
|
+
LOG.error(format_ldap_error(err), exc_info=err)
|
|
75
73
|
|
|
76
74
|
if not settings.BASE_DN:
|
|
77
75
|
LOG.critical("An LDAP base DN is required!")
|
|
@@ -82,158 +80,53 @@ if not settings.SCHEMA_DN:
|
|
|
82
80
|
sys.exit(1)
|
|
83
81
|
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return dn
|
|
97
|
-
|
|
98
|
-
except HTTPException:
|
|
99
|
-
pass # No unique result
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
class LdapConnectionMiddleware(BaseHTTPMiddleware):
|
|
103
|
-
async def dispatch(
|
|
104
|
-
self, request: Request, call_next: RequestResponseEndpoint
|
|
105
|
-
) -> Response:
|
|
106
|
-
"Add an authenticated LDAP connection to the request"
|
|
107
|
-
|
|
108
|
-
# No authentication required for static files
|
|
109
|
-
if not request.url.path.startswith("/api"):
|
|
110
|
-
return await call_next(request)
|
|
111
|
-
|
|
112
|
-
try:
|
|
113
|
-
with ldap_connect() as connection:
|
|
114
|
-
# Hard-wired credentials
|
|
115
|
-
dn = settings.GET_BIND_DN()
|
|
116
|
-
password = settings.GET_BIND_PASSWORD()
|
|
117
|
-
|
|
118
|
-
# Search for basic auth user
|
|
119
|
-
if not dn and type(request.user) is LdapUser:
|
|
120
|
-
password = request.user.password
|
|
121
|
-
dn = settings.GET_BIND_PATTERN(
|
|
122
|
-
request.user.username
|
|
123
|
-
) or await anonymous_user_search(connection, request.user.username)
|
|
124
|
-
|
|
125
|
-
if dn: # Log in
|
|
126
|
-
await empty(connection, connection.simple_bind(dn, password))
|
|
127
|
-
request.state.ldap = connection
|
|
128
|
-
return await call_next(request)
|
|
129
|
-
|
|
130
|
-
except INVALID_CREDENTIALS:
|
|
131
|
-
pass
|
|
132
|
-
|
|
133
|
-
except INSUFFICIENT_ACCESS as err:
|
|
134
|
-
return Response(
|
|
135
|
-
ldap_exception_message(err),
|
|
136
|
-
status_code=HTTPStatus.FORBIDDEN.value,
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
except UNWILLING_TO_PERFORM:
|
|
140
|
-
LOG.warning("Need BIND_DN or BIND_PATTERN to authenticate")
|
|
141
|
-
return Response(
|
|
142
|
-
HTTPStatus.FORBIDDEN.phrase,
|
|
143
|
-
status_code=HTTPStatus.FORBIDDEN.value,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
except LDAPError as err:
|
|
147
|
-
LOG.error(ldap_exception_message(err), exc_info=err)
|
|
148
|
-
return Response(
|
|
149
|
-
ldap_exception_message(err),
|
|
150
|
-
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
return Response(
|
|
154
|
-
HTTPStatus.UNAUTHORIZED.phrase,
|
|
155
|
-
status_code=HTTPStatus.UNAUTHORIZED.value,
|
|
156
|
-
headers={
|
|
157
|
-
# Trigger authentication
|
|
158
|
-
"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'
|
|
159
|
-
},
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
class LdapUser(SimpleUser):
|
|
164
|
-
"LDAP credentials"
|
|
165
|
-
|
|
166
|
-
def __init__(self, username: str, password: str):
|
|
167
|
-
super().__init__(username)
|
|
168
|
-
self.password = password
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
class BasicAuthBackend(AuthenticationBackend):
|
|
172
|
-
"Handle basic authentication"
|
|
173
|
-
|
|
174
|
-
async def authenticate(self, conn: HTTPConnection):
|
|
175
|
-
"Place LDAP credentials in request.user"
|
|
176
|
-
|
|
177
|
-
if "Authorization" in conn.headers:
|
|
178
|
-
try:
|
|
179
|
-
auth = conn.headers["Authorization"]
|
|
180
|
-
scheme, credentials = auth.split()
|
|
181
|
-
if scheme.lower() == "basic":
|
|
182
|
-
decoded = base64.b64decode(credentials).decode("ascii")
|
|
183
|
-
username, _, password = decoded.partition(":")
|
|
184
|
-
return (
|
|
185
|
-
AuthCredentials(["authenticated"]),
|
|
186
|
-
LdapUser(username, password),
|
|
187
|
-
)
|
|
188
|
-
except (ValueError, UnicodeDecodeError, binascii.Error) as _exc:
|
|
189
|
-
raise AuthenticationError("Invalid basic auth credentials")
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
class CacheBustingMiddleware(BaseHTTPMiddleware):
|
|
83
|
+
# Main ASGI entry
|
|
84
|
+
|
|
85
|
+
app = FastAPI(debug=settings.DEBUG, title="LDAP UI", version=__version__)
|
|
86
|
+
app.include_router(api)
|
|
87
|
+
app.mount("/", StaticFiles(packages=["ldap_ui"], html=True))
|
|
88
|
+
|
|
89
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.middleware("http")
|
|
93
|
+
async def cache_buster(request: Request, call_next) -> Response:
|
|
193
94
|
"Forbid caching of API responses"
|
|
95
|
+
response = await call_next(request)
|
|
96
|
+
if request.url.path.startswith("/api"):
|
|
97
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
98
|
+
response.headers["Pragma"] = "no-cache"
|
|
99
|
+
response.headers["Expires"] = "0"
|
|
100
|
+
return response
|
|
194
101
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
status_code=exc.status_code,
|
|
212
|
-
headers=exc.headers,
|
|
102
|
+
|
|
103
|
+
# API error handling
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def error_response(
|
|
107
|
+
exc: LDAPError, status: HTTPStatus, headers: dict[str, str] | None = None
|
|
108
|
+
) -> JSONResponse:
|
|
109
|
+
return JSONResponse({"detail": [format_ldap_error(exc)]}, status, headers=headers)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.exception_handler(INVALID_CREDENTIALS)
|
|
113
|
+
def handle_invalid_credentials(_request: Request, exc: INVALID_CREDENTIALS):
|
|
114
|
+
return error_response(
|
|
115
|
+
exc,
|
|
116
|
+
HTTPStatus.UNAUTHORIZED,
|
|
117
|
+
headers={"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'},
|
|
213
118
|
)
|
|
214
119
|
|
|
215
120
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
LOG.
|
|
219
|
-
return
|
|
121
|
+
@app.exception_handler(UNWILLING_TO_PERFORM)
|
|
122
|
+
def handle_unwilling_to_perform(_request: Request, exc: UNWILLING_TO_PERFORM):
|
|
123
|
+
LOG.warning("Need BIND_DN or BIND_PATTERN to authenticate")
|
|
124
|
+
return error_response(exc, HTTPStatus.FORBIDDEN)
|
|
220
125
|
|
|
221
126
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
},
|
|
229
|
-
middleware=(
|
|
230
|
-
Middleware(AuthenticationMiddleware, backend=BasicAuthBackend()),
|
|
231
|
-
Middleware(LdapConnectionMiddleware),
|
|
232
|
-
Middleware(CacheBustingMiddleware),
|
|
233
|
-
Middleware(GZipMiddleware, minimum_size=512, compresslevel=6),
|
|
234
|
-
),
|
|
235
|
-
routes=[
|
|
236
|
-
Mount("/api", app=api),
|
|
237
|
-
Mount("/", StaticFiles(packages=["ldap_ui"], html=True)),
|
|
238
|
-
],
|
|
239
|
-
)
|
|
127
|
+
@app.exception_handler(LDAPError)
|
|
128
|
+
def handle_ldap_error(_request: Request, exc: LDAPError):
|
|
129
|
+
"General handler for other LDAP errors"
|
|
130
|
+
return error_response(
|
|
131
|
+
exc, LDAP_ERROR_TO_STATUS.get(type(exc), HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
132
|
+
)
|
ldap_ui/entities.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"Data types for ReST endpoints"
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TreeItem(BaseModel):
|
|
7
|
+
"Entry in the navigation tree"
|
|
8
|
+
|
|
9
|
+
dn: str
|
|
10
|
+
structuralObjectClass: str
|
|
11
|
+
hasSubordinates: bool
|
|
12
|
+
level: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Meta(BaseModel):
|
|
16
|
+
"Attribute classification for an entry"
|
|
17
|
+
|
|
18
|
+
dn: str
|
|
19
|
+
required: list[str]
|
|
20
|
+
aux: list[str]
|
|
21
|
+
binary: list[str]
|
|
22
|
+
autoFilled: list[str]
|
|
23
|
+
isNew: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Attributes = dict[str, list[str]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Entry(BaseModel):
|
|
30
|
+
"Directory entry"
|
|
31
|
+
|
|
32
|
+
attrs: Attributes
|
|
33
|
+
meta: Meta
|
|
34
|
+
changed: list[str] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChangedAttributes(BaseModel):
|
|
38
|
+
"List of modified attributes"
|
|
39
|
+
|
|
40
|
+
changed: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChangePasswordRequest(BaseModel):
|
|
44
|
+
"Change a password"
|
|
45
|
+
|
|
46
|
+
old: str
|
|
47
|
+
new1: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SearchResult(BaseModel):
|
|
51
|
+
"Search result"
|
|
52
|
+
|
|
53
|
+
dn: str
|
|
54
|
+
name: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Range(BaseModel):
|
|
58
|
+
"Numeric attribute range"
|
|
59
|
+
|
|
60
|
+
min: int
|
|
61
|
+
max: int
|
|
62
|
+
next: int
|