ldap-ui 0.9.15__py3-none-any.whl → 0.10.1__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 +63 -212
- ldap_ui/entities.py +62 -0
- ldap_ui/ldap_api.py +419 -352
- ldap_ui/ldap_helpers.py +74 -34
- ldap_ui/schema.py +8 -10
- ldap_ui/statics/assets/index-BxCLA1wZ.js +19 -0
- ldap_ui/statics/assets/index-BxCLA1wZ.js.gz +0 -0
- ldap_ui/statics/assets/{index-qocMa2qY.css → index-CusJ2HRh.css} +1 -1
- ldap_ui/statics/assets/index-CusJ2HRh.css.gz +0 -0
- ldap_ui/statics/index.html +2 -2
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.1.dist-info}/METADATA +8 -9
- ldap_ui-0.10.1.dist-info/RECORD +25 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.1.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.1.dist-info}/entry_points.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.1.dist-info}/licenses/LICENSE.txt +0 -0
- {ldap_ui-0.9.15.dist-info → ldap_ui-0.10.1.dist-info}/top_level.txt +0 -0
ldap_ui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.10.1"
|
ldap_ui/app.py
CHANGED
|
@@ -8,232 +8,83 @@ 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
|
-
import sys
|
|
15
12
|
from http import HTTPStatus
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
from starlette.authentication import (
|
|
30
|
-
AuthCredentials,
|
|
31
|
-
AuthenticationBackend,
|
|
32
|
-
AuthenticationError,
|
|
33
|
-
SimpleUser,
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request, Response
|
|
15
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
from ldap import ( # type: ignore
|
|
19
|
+
ALREADY_EXISTS, # type: ignore
|
|
20
|
+
INSUFFICIENT_ACCESS, # type: ignore
|
|
21
|
+
INVALID_CREDENTIALS, # type: ignore
|
|
22
|
+
NO_SUCH_OBJECT, # type: ignore
|
|
23
|
+
OBJECT_CLASS_VIOLATION, # type: ignore
|
|
24
|
+
UNWILLING_TO_PERFORM, # type: ignore # type: ignore
|
|
25
|
+
LDAPError, # type: ignore
|
|
34
26
|
)
|
|
35
|
-
from starlette.exceptions import HTTPException
|
|
36
|
-
from starlette.middleware import Middleware
|
|
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
|
|
46
|
-
from .ldap_api import api
|
|
47
|
-
from .ldap_helpers import WITH_OPERATIONAL_ATTRS, empty, ldap_connect, unique
|
|
48
|
-
|
|
49
|
-
LOG = logging.getLogger("ldap-ui")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def ldap_exception_message(exc: LDAPError) -> str:
|
|
53
|
-
args = exc.args[0]
|
|
54
|
-
if "info" in args:
|
|
55
|
-
return args.get("info", "") + ": " + args.get("desc", "")
|
|
56
|
-
return args.get("desc", "")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if not settings.BASE_DN or not settings.SCHEMA_DN:
|
|
60
|
-
# Try auto-detection from root DSE
|
|
61
|
-
try:
|
|
62
|
-
with ldap_connect() as connection:
|
|
63
|
-
_dn, attrs = connection.search_s( # pyright: ignore[reportAssignmentType, reportOptionalSubscript]
|
|
64
|
-
"", SCOPE_BASE, attrlist=WITH_OPERATIONAL_ATTRS
|
|
65
|
-
)[0]
|
|
66
|
-
base_dns = attrs.get("namingContexts", [])
|
|
67
|
-
if len(base_dns) == 1:
|
|
68
|
-
settings.BASE_DN = settings.BASE_DN or base_dns[0].decode()
|
|
69
|
-
else:
|
|
70
|
-
LOG.warning("No unique base DN: %s", base_dns)
|
|
71
|
-
schema_dns = attrs.get("subschemaSubentry", [])
|
|
72
|
-
settings.SCHEMA_DN = settings.SCHEMA_DN or schema_dns[0].decode()
|
|
73
|
-
except LDAPError as err:
|
|
74
|
-
LOG.error(ldap_exception_message(err), exc_info=err)
|
|
75
|
-
|
|
76
|
-
if not settings.BASE_DN:
|
|
77
|
-
LOG.critical("An LDAP base DN is required!")
|
|
78
|
-
sys.exit(1)
|
|
79
|
-
|
|
80
|
-
if not settings.SCHEMA_DN:
|
|
81
|
-
LOG.critical("An LDAP schema DN is required!")
|
|
82
|
-
sys.exit(1)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
async def anonymous_user_search(connection: LDAPObject, username: str) -> Optional[str]:
|
|
86
|
-
try:
|
|
87
|
-
# No BIND_PATTERN, try anonymous search
|
|
88
|
-
dn, _attrs = await unique(
|
|
89
|
-
connection,
|
|
90
|
-
connection.search(
|
|
91
|
-
settings.BASE_DN,
|
|
92
|
-
SCOPE_SUBTREE,
|
|
93
|
-
settings.GET_BIND_DN_FILTER(username),
|
|
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
27
|
|
|
153
|
-
|
|
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
|
-
)
|
|
28
|
+
from . import __version__, ldap_api, settings
|
|
161
29
|
|
|
30
|
+
# Main ASGI entry
|
|
162
31
|
|
|
163
|
-
|
|
164
|
-
|
|
32
|
+
app = FastAPI(debug=settings.DEBUG, title="LDAP UI", version=__version__)
|
|
33
|
+
app.include_router(ldap_api.api)
|
|
34
|
+
app.mount("/", StaticFiles(packages=["ldap_ui"], html=True))
|
|
165
35
|
|
|
166
|
-
|
|
167
|
-
super().__init__(username)
|
|
168
|
-
self.password = password
|
|
36
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
|
|
169
37
|
|
|
170
38
|
|
|
171
|
-
|
|
172
|
-
|
|
39
|
+
@app.middleware("http")
|
|
40
|
+
async def cache_buster(request: Request, call_next) -> Response:
|
|
41
|
+
"Forbid caching of API responses"
|
|
42
|
+
response = await call_next(request)
|
|
43
|
+
if request.url.path.startswith("/api"):
|
|
44
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
45
|
+
response.headers["Pragma"] = "no-cache"
|
|
46
|
+
response.headers["Expires"] = "0"
|
|
47
|
+
return response
|
|
173
48
|
|
|
174
|
-
async def authenticate(self, conn: HTTPConnection):
|
|
175
|
-
"Place LDAP credentials in request.user"
|
|
176
49
|
|
|
177
|
-
|
|
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")
|
|
50
|
+
# API error handling
|
|
190
51
|
|
|
52
|
+
LDAP_ERROR_TO_STATUS = {
|
|
53
|
+
ALREADY_EXISTS: HTTPStatus.CONFLICT,
|
|
54
|
+
INSUFFICIENT_ACCESS: HTTPStatus.FORBIDDEN,
|
|
55
|
+
INVALID_CREDENTIALS: HTTPStatus.UNAUTHORIZED,
|
|
56
|
+
NO_SUCH_OBJECT: HTTPStatus.NOT_FOUND,
|
|
57
|
+
OBJECT_CLASS_VIOLATION: HTTPStatus.BAD_REQUEST,
|
|
58
|
+
UNWILLING_TO_PERFORM: HTTPStatus.FORBIDDEN,
|
|
59
|
+
}
|
|
191
60
|
|
|
192
|
-
class CacheBustingMiddleware(BaseHTTPMiddleware):
|
|
193
|
-
"Forbid caching of API responses"
|
|
194
|
-
|
|
195
|
-
async def dispatch(
|
|
196
|
-
self, request: Request, call_next: RequestResponseEndpoint
|
|
197
|
-
) -> Response:
|
|
198
|
-
response = await call_next(request)
|
|
199
|
-
if request.url.path.startswith("/api"):
|
|
200
|
-
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
201
|
-
response.headers["Pragma"] = "no-cache"
|
|
202
|
-
response.headers["Expires"] = "0"
|
|
203
|
-
return response
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
async def http_exception(_request: Request, exc: HTTPException) -> Response:
|
|
207
|
-
"Send error responses"
|
|
208
|
-
assert exc.status_code >= 400
|
|
209
|
-
return Response(
|
|
210
|
-
exc.detail,
|
|
211
|
-
status_code=exc.status_code,
|
|
212
|
-
headers=exc.headers,
|
|
213
|
-
)
|
|
214
61
|
|
|
62
|
+
@app.exception_handler(LDAPError)
|
|
63
|
+
def handle_ldap_error(request: Request, exc: LDAPError) -> Response:
|
|
64
|
+
"General handler for LDAP errors"
|
|
215
65
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return Response(repr(e), status_code=HTTPStatus.UNPROCESSABLE_ENTITY.value)
|
|
66
|
+
exc_type = type(exc)
|
|
67
|
+
if exc_type is UNWILLING_TO_PERFORM:
|
|
68
|
+
logging.critical("Need BIND_DN or BIND_PATTERN to authenticate")
|
|
220
69
|
|
|
70
|
+
if exc_type is INVALID_CREDENTIALS:
|
|
71
|
+
return Response(
|
|
72
|
+
status_code=HTTPStatus.UNAUTHORIZED,
|
|
73
|
+
headers={
|
|
74
|
+
"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'
|
|
75
|
+
},
|
|
76
|
+
)
|
|
221
77
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
)
|
|
235
|
-
routes=[
|
|
236
|
-
Mount("/api", app=api),
|
|
237
|
-
Mount("/", StaticFiles(packages=["ldap_ui"], html=True)),
|
|
238
|
-
],
|
|
239
|
-
)
|
|
78
|
+
if exc_type not in LDAP_ERROR_TO_STATUS:
|
|
79
|
+
# Unknown error --> log it since FastApi won't do it for us
|
|
80
|
+
logging.exception("Error in %s %s:", request.method, request.url, exc_info=exc)
|
|
81
|
+
|
|
82
|
+
cause = exc.args[0] if exc.args else {}
|
|
83
|
+
desc = cause.get("desc", "LDAP error").capitalize()
|
|
84
|
+
msg = f"{desc}" if "info" not in cause else f"{desc}: {cause['info'].capitalize()}"
|
|
85
|
+
return JSONResponse(
|
|
86
|
+
{"detail": [msg]},
|
|
87
|
+
status_code=LDAP_ERROR_TO_STATUS.get(
|
|
88
|
+
exc_type, HTTPStatus.INTERNAL_SERVER_ERROR
|
|
89
|
+
),
|
|
90
|
+
)
|
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
|