Skip to content

Commit

Permalink
#55 Add image version, improve error logging
Browse files Browse the repository at this point in the history
  • Loading branch information
dnknth committed Nov 21, 2024
1 parent b4bdf9c commit 646e0b9
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 87 deletions.
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
.PHONY: debug run clean tidy image push manifest

TAG = latest-$(subst aarch64,arm64,$(shell uname -m))
SITE = backend/ldap_ui/statics
VERSION = $(shell fgrep __version__ backend/ldap_ui/__init__.py | cut -d'"' -f2)
TAG = $(VERSION)-$(subst aarch64,arm64,$(shell uname -m))

debug: .env .venv3 $(SITE)
.venv3/bin/uvicorn --reload --port 5000 ldap_ui.app:app
Expand All @@ -19,7 +20,7 @@ dist: .venv3 $(SITE)
.venv3/bin/python3 -m build --wheel

pypi: clean dist
.venv3/bin/twine upload dist/*
- .venv3/bin/twine upload dist/*

$(SITE): node_modules
npm audit
Expand All @@ -35,7 +36,7 @@ clean:
tidy: clean
rm -rf .venv3 node_modules

image:
image: pypi
docker build --no-cache -t dnknth/ldap-ui:$(TAG) .

push: image
Expand All @@ -44,7 +45,7 @@ push: image
manifest:
docker manifest create \
dnknth/ldap-ui \
--amend dnknth/ldap-ui:latest-x86_64 \
--amend dnknth/ldap-ui:latest-arm64
--amend dnknth/ldap-ui:$(VERSION)-x86_64 \
--amend dnknth/ldap-ui:$(VERSION)-arm64
docker manifest push --purge dnknth/ldap-ui
docker compose pull
2 changes: 1 addition & 1 deletion backend/ldap_ui/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.9.8"
__version__ = "0.9.9"
111 changes: 57 additions & 54 deletions backend/ldap_ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
import logging
import sys
from http import HTTPStatus
from typing import AsyncGenerator
from typing import AsyncGenerator, Optional

import ldap
from ldap.ldapobject import LDAPObject
from pydantic import ValidationError
from starlette.applications import Starlette
from starlette.authentication import (
Expand All @@ -30,7 +31,7 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import HTTPConnection, Request
from starlette.responses import PlainTextResponse, Response
from starlette.responses import Response
from starlette.routing import Mount
from starlette.staticfiles import StaticFiles

Expand All @@ -46,12 +47,22 @@

LOG.debug("Base DN: %s", settings.BASE_DN)

# Force authentication
UNAUTHORIZED = Response(
HTTPStatus.UNAUTHORIZED.phrase,
status_code=HTTPStatus.UNAUTHORIZED.value,
headers={"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'},
)

async def anonymous_user_search(connection: LDAPObject, username: str) -> Optional[str]:
try:
# No BIND_PATTERN, try anonymous search
dn, _attrs = await unique(
connection,
connection.search(
settings.BASE_DN,
ldap.SCOPE_SUBTREE,
settings.GET_BIND_DN_FILTER(username),
),
)
return dn

except HTTPException:
pass # No unique result


class LdapConnectionMiddleware(BaseHTTPMiddleware):
Expand All @@ -60,55 +71,60 @@ async def dispatch(
) -> Response:
"Add an authenticated LDAP connection to the request"

# Short-circuit static files
# No authentication required for static files
if not request.url.path.startswith("/api"):
return await call_next(request)

try:
with ldap_connect() as connection:
dn, password = None, None
# Hard-wired credentials
dn = settings.GET_BIND_DN()
password = settings.GET_BIND_PASSWORD()

# Search for basic auth user
if type(request.user) is LdapUser:
if not dn and type(request.user) is LdapUser:
password = request.user.password
dn = settings.GET_BIND_PATTERN(request.user.username)
if dn is None:
try:
dn, _attrs = await unique(
connection,
connection.search(
settings.BASE_DN,
ldap.SCOPE_SUBTREE,
settings.GET_BIND_DN_FILTER(request.user.username),
),
)
except HTTPException:
pass
dn = settings.GET_BIND_PATTERN(
request.user.username
) or await anonymous_user_search(connection, request.user.username)

# Hard-wired credentials
if dn is None:
dn = settings.GET_BIND_DN(request.user.display_name)
password = settings.GET_BIND_PASSWORD()
if dn: # Log in
await empty(connection, connection.simple_bind(dn, password))
request.state.ldap = connection
return await call_next(request)

if dn is None:
return UNAUTHORIZED
except ldap.INVALID_CREDENTIALS:
pass

# Log in
await empty(connection, connection.simple_bind(dn, password))
request.state.ldap = connection
return await call_next(request)
except ldap.INSUFFICIENT_ACCESS as err:
return Response(
ldap_exception_message(err),
status_code=HTTPStatus.FORBIDDEN.value,
)

except ldap.INVALID_CREDENTIALS:
return UNAUTHORIZED
except ldap.UNWILLING_TO_PERFORM:
LOG.warning("Need BIND_DN or BIND_PATTERN to authenticate")
return Response(
HTTPStatus.FORBIDDEN.phrase,
status_code=HTTPStatus.FORBIDDEN.value,
)

except ldap.LDAPError as err:
msg = ldap_exception_message(err)
LOG.error(msg, exc_info=err)
return PlainTextResponse(
msg,
LOG.error(ldap_exception_message(err), exc_info=err)
return Response(
ldap_exception_message(err),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
)

return Response(
HTTPStatus.UNAUTHORIZED.phrase,
status_code=HTTPStatus.UNAUTHORIZED.value,
headers={
# Trigger authentication
"WWW-Authenticate": 'Basic realm="Please log in", charset="UTF-8"'
},
)


def ldap_exception_message(exc: ldap.LDAPError) -> str:
args = exc.args[0]
Expand Down Expand Up @@ -163,25 +179,13 @@ async def dispatch(
async def http_exception(_request: Request, exc: HTTPException) -> Response:
"Send error responses"
assert exc.status_code >= 400
if exc.status_code < 500:
LOG.warning(exc.detail)
else:
LOG.error(exc.detail)
return PlainTextResponse(
return Response(
exc.detail,
status_code=exc.status_code,
headers=exc.headers,
)


async def forbidden(_request: Request, exc: ldap.LDAPError) -> Response:
"HTTP 403 Forbidden"
return PlainTextResponse(
ldap_exception_message(exc),
status_code=HTTPStatus.FORBIDDEN.value,
)


async def http_422(_request: Request, e: ValidationError) -> Response:
"HTTP 422 Unprocessable Entity"
LOG.warn("Invalid request body", exc_info=e)
Expand All @@ -193,7 +197,6 @@ async def http_422(_request: Request, e: ValidationError) -> Response:
debug=settings.DEBUG,
exception_handlers={
HTTPException: http_exception,
ldap.INSUFFICIENT_ACCESS: forbidden,
ValidationError: http_422,
},
middleware=(
Expand Down
55 changes: 36 additions & 19 deletions backend/ldap_ui/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,50 @@
# LDAP settings
#
LDAP_URL = config("LDAP_URL", default="ldap:///")
BASE_DN = config("BASE_DN", default=None)
BASE_DN = config("BASE_DN", default=None) # Required

USE_TLS = config(
"USE_TLS",
cast=lambda x: bool(x),
default=LDAP_URL.startswith("ldaps://"),
)
INSECURE_TLS = config("INSECURE_TLS", cast=lambda x: bool(x), default=False)

# DANGEROUS: Disable TLS host name verification.
INSECURE_TLS = config(
"INSECURE_TLS",
cast=lambda x: bool(x),
default=False,
)

# OpenLdap default DN to obtain the schema.
# Change as needed for other directories.
SCHEMA_DN = config("SCHEMA_DN", default="cn=subschema")


#
# Binding
#
def GET_BIND_DN(username) -> Optional[str]:
"Try to determine the login DN from the environment and request"

# Use a hard-wired DN from the environment.
# If this is set and a GET_BIND_PASSWORD returns something,
# the UI will NOT ask for a login.
# You need to secure it otherwise!

def GET_BIND_DN() -> Optional[str]:
"""
Try to find a hard-wired DN from in the environment.
If this is present and GET_BIND_PASSWORD returns something,
the UI will NOT ask for a login.
You need to secure it otherwise!
"""
if config("BIND_DN", default=None):
return config("BIND_DN")

return GET_BIND_PATTERN(username)


def GET_BIND_PATTERN(username) -> Optional[str]:
"Determine the bind pattern from the environment and request"
# Optional user DN pattern string for authentication,
# e.g. "uid=%s,ou=people,dc=example,dc=com".
# This can be used to authenticate with directories
# that do not allow anonymous users to search.
"""
Apply an optional user DN pattern for authentication
from the environment,
e.g. "uid=%s,ou=people,dc=example,dc=com".
This can be used to authenticate with directories
that do not allow anonymous users to search.
"""
if config("BIND_PATTERN", default=None) and username:
return config("BIND_PATTERN") % username

Expand All @@ -59,7 +69,6 @@ def GET_BIND_DN_FILTER(username) -> str:

def GET_BIND_PASSWORD() -> Optional[str]:
"Try to determine the login password from the environment or request"

pw = config("BIND_PASSWORD", default=None)
if pw is not None:
return pw
Expand All @@ -84,7 +93,15 @@ def GET_BIND_PASSWORD() -> Optional[str]:
"(gn=%s*)",
"(sn=%s*)",
)

SEARCH_QUERY_MIN = config(
"SEARCH_QUERY_MIN", cast=int, default=2
) # Minimum length of query term
SEARCH_MAX = config("SEARCH_MAX", cast=int, default=50) # Maximum number of results
"SEARCH_QUERY_MIN", # Minimum length of query term
cast=int,
default=2,
)

SEARCH_MAX = config(
"SEARCH_MAX", # Maximum number of results
cast=int,
default=50,
)
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ onMounted(async () => { // Runs on page load
const whoamiResponse = await fetch('api/whoami');
if (whoamiResponse.ok) {
user.value = await whoamiResponse.json();
}
// Load the schema
const schemaResponse = await fetch('api/schema');
if (schemaResponse.ok) {
schema.value = new LdapSchema(await schemaResponse.json());
// Load the schema
const schemaResponse = await fetch('api/schema');
if (schemaResponse.ok) {
schema.value = new LdapSchema(await schemaResponse.json());
}
}
});
Expand Down

0 comments on commit 646e0b9

Please sign in to comment.