ldap-ui 0.9.14__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.9.14"
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
- 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,
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
- 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
30
+
31
+ from . import __version__, settings
46
32
  from .ldap_api import api
47
- from .ldap_helpers import WITH_OPERATIONAL_ATTRS, empty, ldap_connect, unique
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
- 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", "")
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
- # 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
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(ldap_exception_message(err), exc_info=err)
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
- 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
-
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
- 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,
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
- 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)
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
- # 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
- )
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