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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.9.15"
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
- from typing import Optional
17
-
18
- from ldap import (
19
- INSUFFICIENT_ACCESS, # pyright: ignore[reportAttributeAccessIssue]
20
- INVALID_CREDENTIALS, # pyright: ignore[reportAttributeAccessIssue]
21
- SCOPE_BASE, # pyright: ignore[reportAttributeAccessIssue]
22
- SCOPE_SUBTREE, # pyright: ignore[reportAttributeAccessIssue]
23
- UNWILLING_TO_PERFORM, # pyright: ignore[reportAttributeAccessIssue]
24
- LDAPError, # pyright: ignore[reportAttributeAccessIssue]
25
- )
26
- from ldap.ldapobject import LDAPObject
27
- from pydantic import ValidationError
28
- from starlette.applications import Starlette
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
- 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
- )
28
+ from . import __version__, ldap_api, settings
161
29
 
30
+ # Main ASGI entry
162
31
 
163
- class LdapUser(SimpleUser):
164
- "LDAP credentials"
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
- def __init__(self, username: str, password: str):
167
- super().__init__(username)
168
- self.password = password
36
+ app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
169
37
 
170
38
 
171
- class BasicAuthBackend(AuthenticationBackend):
172
- "Handle basic authentication"
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
- 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")
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
- async def http_422(_request: Request, e: ValidationError) -> Response:
217
- "HTTP 422 Unprocessable Entity"
218
- LOG.warn("Invalid request body", exc_info=e)
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
- # Main ASGI entry
223
- app = Starlette(
224
- debug=settings.DEBUG,
225
- exception_handlers={ # pyright: ignore[reportArgumentType]
226
- HTTPException: http_exception,
227
- ValidationError: http_422,
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
- )
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