diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e2a681 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.venv +venv +.env +*.sqlite3 +static/ +**/__pycache__ +**/*.pyc +dal/tests +dev +deploy +docker-compose*.yml +compose.yaml +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a61c190 --- /dev/null +++ b/.env.example @@ -0,0 +1,100 @@ +# Environment for the ungleich account webapp (account.ungleich.ch). +# +# Copy to .env for `docker compose` / `docker run --env-file`, or use it as +# the list of variables to inject in Kubernetes. docker.md explains each one. +# Values marked [secret] belong in a Secret, not a ConfigMap. + + +# --------------------------------------------------------------------------- +# Required (the app refuses to start without these) +# --------------------------------------------------------------------------- + +# [secret] Long random string. Generate one with: +# python3 -c 'import secrets; print(secrets.token_urlsafe(50))' +SECRET_KEY= + +# LDAP server(s), space separated. The first reachable one is used. +LDAPSERVER="ldaps://ldap1.ungleich.ch ldaps://ldap2.ungleich.ch" + +# Account that searches the directory and creates/changes customers. +LDAP_ADMIN_DN= +LDAP_ADMIN_PASSWORD= # [secret] + +# Sender address of activation and password-reset emails. +EMAIL_FROM_ADDRESS= + + +# --------------------------------------------------------------------------- +# Usually set in production +# --------------------------------------------------------------------------- + +# Behind an HTTPS ingress/proxy that sets X-Forwarded-Proto. Makes links in +# emails use https:// and lets Django know requests were secure. +TRUST_X_FORWARDED_PROTO=True + +# PostgreSQL. Required for more than one replica; without DATABASE_HOST the +# app uses SQLite at SQLITE_PATH (one replica, on a persistent volume). +#DATABASE_HOST= +#DATABASE_PORT=5432 +#DATABASE_NAME=ungleich_user +#DATABASE_USER=ungleich_user +#DATABASE_PASSWORD= # [secret] +#DATABASE_SSLMODE=prefer + +# Mail relay (defaults: localhost:25 with STARTTLS). +#EMAIL_HOST=localhost +#EMAIL_PORT=25 +#EMAIL_USE_TLS=True +#EMAIL_HOST_USER= +#EMAIL_HOST_PASSWORD= # [secret] + +# OTP server. Optional: without OTPSERVER new accounts get no OTP seed. +# If OTPSERVER is set, the four variables after it are required too. +#OTPSERVER= +#ADMIN_SEED= # [secret] base32 TOTP seed of ADMIN_NAME +#ADMIN_NAME= +#ADMIN_REALM= +#USER_REALM= # realm for seeds of new users +#ALLOWED_REALMS= # comma separated realms users may request + + +# --------------------------------------------------------------------------- +# Optional, with production defaults (only set to change them) +# --------------------------------------------------------------------------- + +#ALLOWED_HOSTS=account.ungleich.ch # comma separated +#CSRF_TRUSTED_ORIGINS=https://account.ungleich.ch # default: https:// + each allowed host + +# Directory layout. A different tree usually only needs ENTIRE_SEARCH_BASE. +#ENTIRE_SEARCH_BASE=dc=ungleich,dc=ch +#LDAP_CUSTOMER_DN=ou=customer,dc=ungleich,dc=ch # where registrations go +#LDAPSEARCH="ou=customer,dc=ungleich,dc=ch ou=users,dc=ungleich,dc=ch" # where logins are found +#LDAP_CUSTOMER_GROUP_ID=10004 +#LDAP_DEFAULT_START_UID=10000 +#LDAP_USER_OBJECT_CLASSES="inetOrgPerson posixAccount ldapPublicKey" # drop ldapPublicKey without the openssh-lpk schema + +# LDAP TLS +#LDAP_USE_TLS=False # StartTLS, for ldap:// URIs only +#LDAP_TLS_VERIFY=True # verify server certificates +#LDAP_CA_FILE= # CA bundle for a private CA; default: system trust store + +# Security headers +#SESSION_COOKIE_SECURE=True # default: True unless DEBUG +#CSRF_COOKIE_SECURE=True +#SECURE_SSL_REDIRECT=False # usually the ingress redirects to HTTPS +#SECURE_HSTS_SECONDS=0 + +# Runtime +#DEBUG=False +#LOG_LEVEL=INFO +#DJANGO_LOG_LEVEL=INFO +#LDAP3_LOG=False # very verbose ldap3 protocol logging +#SQLITE_PATH=/data/db.sqlite3 +#DATABASE_CONN_MAX_AGE=60 +#EMAIL_TIMEOUT=10 +#OTP_TIMEOUT=10 +#STATIC_ROOT=/app/static +#GUNICORN_WORKERS=2 +#GUNICORN_THREADS=4 +#GUNICORN_TIMEOUT=60 +#GUNICORN_BIND=[::]:8000 diff --git a/.gitignore b/.gitignore index 38b9c32..2af37cc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ secret-key *.mo venv/ -dal/ldap_max_uid_file + +# dev LDAP CA certificate, regenerated on every start +dev/ldap/export/ diff --git a/Dockerfile b/Dockerfile index e3ec123..05f7a15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,50 @@ -FROM python:3.10.0-alpine3.15 +# syntax=docker/dockerfile:1 +# +# Production image for the ungleich account webapp. +# All configuration comes from environment variables: see docker.md and +# .env.example. +# +# docker build -t ungleich-user . +# docker run --env-file .env -p 8000:8000 ungleich-user +# +# Database migrations are not run on start; run them once per release: +# docker run --env-file .env ungleich-user python manage.py migrate_locked -WORKDIR /usr/src/app +FROM python:3.15-slim-trixie -RUN apk add --update --no-cache\ - build-base \ - openldap-dev\ - python3-dev\ - && rm -rf /var/cache/apk/* +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 -# FIX https://github.com/python-ldap/python-ldap/issues/432 -RUN echo 'INPUT ( libldap.so )' > /usr/lib/libldap_r.so +# Security updates for the base image. No sshd or compiler is installed: +# every Python dependency ships as a pure Python or binary wheel. +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --no-create-home --home-dir /app app \ + && mkdir /data && chown 10001 /data -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -COPY ungleichuser/ . +# Only used without DATABASE_HOST: SQLite lives on a volume mounted at +# /data (owned by the app user, so a fresh named volume is writable). +ENV SQLITE_PATH=/data/db.sqlite3 +VOLUME /data + +WORKDIR /app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY manage.py gunicorn.conf.py ./ +COPY dal ./dal + +# Settings need the required variables to load; these placeholders exist +# only for this one command and are not part of the image's environment. +RUN SECRET_KEY=build LDAPSERVER=ldap://build LDAP_ADMIN_DN=build \ + LDAP_ADMIN_PASSWORD=build EMAIL_FROM_ADDRESS=build@example.com \ + python manage.py collectstatic --noinput -v0 + +USER 10001 +EXPOSE 8000 + +CMD ["gunicorn", "dal.wsgi:application"] diff --git a/README.md b/README.md index 0a1cb2d..d8efcc0 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,67 @@ -# Ungleich Account Management Webapp +# ungleich account webapp -This service runs on [account.ungleich.ch](https://account.ungleich.ch/) and -allows customers manage their ungleich account (register, edit mail & password, -...). +This service runs on [account.ungleich.ch](https://account.ungleich.ch/). +Customers use it to manage their ungleich LDAP account: register (with +email confirmation), log in, change their name, email or password, reset a +forgotten password, delete the account, and view their OTP seeds. It also +offers a small API for creating accounts and fetching OTP seeds. -## Status v2 +Django 6.1 on Python 3.15. LDAP access goes through +[ldap3](https://ldap3.readthedocs.io/) only, with no C extensions. -* See below ungleichuser/ -* register view created -* Not solved: - * email verification *before* user is created -* Solved - * Finding highest uid, increasing by 1 +## Development -## Todos for v2: - -* Rewrite/create new app Django 4.0 based (ungleichuser) -* Do not use django-auth-ldap, because it uses python-ldap -* Update the template HTML to bootstrap5 -* Use ldap3 -* Check/verify the ldap registration -* Ensure multiple ldap servers are supported -* Cleanup this readme -* Use the following "captcha" for all forms: - * Extra form field named "random" - * Present the user the following text: -* Create Dockerfile / ensure it works in kubernetes - -## Next steps - -* Override/use custom form to allow captcha - -## Development Setup +You need Docker and [uv](https://docs.astral.sh/uv/) (or any Python 3.15). +Only LDAP (and optionally PostgreSQL) runs in containers. The app runs +natively. +```sh +docker compose -f docker-compose.dev.yml up -d --build # OpenLDAP 2.6 on :3389, PostgreSQL on :5433 +cp dev/env.dev .env # dev-only settings +uv venv -p 3.15 .venv && uv pip install -r requirements.txt +.venv/bin/python manage.py migrate +.venv/bin/python manage.py runserver ``` -python3 -m venv venv -. ./venv/bin/activate -pip install -r requirements.txt -`` -## Functionality +The test LDAP mirrors the production layout (`dc=ungleich,dc=ch`, +`ou=customer`, `ou=users`) and comes with `customer1` / `customerpass` +and `staff1` / `staffpass`. To reset its data: +`docker compose -f docker-compose.dev.yml up -d --force-recreate ldap`. -x Allow user to register in a specific subtree -x Verify that user does not exist in another subtree -x Assign an id -- Allow password reset [via Mail?] -- DB usage: for password reset? +### Tests -## Parameters +```sh +.venv/bin/python manage.py test dal +``` -Via environment variables: +The tests in `dal/tests/` exercise every view over HTTP against the real +test LDAP server, which must be running. Mail and the OTP server are +mocked. Set `DATABASE_HOST=localhost DATABASE_PORT=5433 +DATABASE_PASSWORD=dev-only` to run them on PostgreSQL instead of SQLite. -* LDAPSERVERS=".." -- White space separated list of LDAP-Servers -* ADMIN_DN="" -- we use this DN to connect to LDAP -* ADMIN_PASSWORD="" -- we use this password to connect to LDAP -* SECRET_KEY -* DEBUG -* ALLOWED_HOSTS +## Configuration +Every setting is an environment variable (a `.env` file in development). +[`.env.example`](.env.example) lists each one, with its default and whether it +is a secret. -## Views +## Deployment -### Register view +The [`Dockerfile`](Dockerfile) builds a production image: gunicorn running +as non-root, static files served by WhiteNoise, and logs on stdout. +[`docker.md`](docker.md) explains how to deploy it with `docker run`, +Docker Compose ([`compose.yaml`](compose.yaml)) or Helm, and which +variables are needed. For the plain Kubernetes manifests, and testing +locally with kind, see [`deploy/k8s/README.md`](deploy/k8s/README.md). -Form: [get] +## Layout -* username -* password1 -* password2 - -Post receiver: [post] - -* E-Mail verification - -E-Mail verify: [get] - -* Create user - -### Password reset view - -* captcha -* username or email +| Path | Contents | +| --- | --- | +| `dal/views.py` | All views | +| `dal/ungleich_ldap.py` | LDAP writes and searches (`LdapManager`, server pool) | +| `dal/backends.py` | LDAP login backend | +| `dal/health.py` | `/healthz` and `/readyz` | +| `dal/tests/` | End-to-end tests | +| `dev/` | Dev settings and the test OpenLDAP image | +| `deploy/k8s/` | Kubernetes manifests (base and kind dev overlay) | diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bb29079 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,61 @@ +# Production-style deployment with Docker Compose: PostgreSQL, a one-off +# migration step, and the web app. Configuration comes from .env +# (copy .env.example). See docker.md. +# +# cp .env.example .env # then fill in the required values +# docker compose up -d --build +# +# For local development use docker-compose.dev.yml instead. + +x-app: &app + build: . + image: ungleich-user:latest + env_file: ${ENV_FILE:-.env} + environment: + DATABASE_HOST: db + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + +services: + db: + image: postgres:18-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DATABASE_NAME:-ungleich_user} + POSTGRES_USER: ${DATABASE_USER:-ungleich_user} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env} + volumes: + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] + interval: 3s + retries: 20 + + # Runs once per `up` and exits; web starts only if it succeeded. + migrate: + <<: *app + command: ["python", "manage.py", "migrate_locked"] + restart: "no" + depends_on: + db: + condition: service_healthy + + web: + <<: *app + restart: unless-stopped + ports: + - "${PORT:-8000}:8000" + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=3)"] + interval: 15s + timeout: 5s + retries: 3 + +volumes: + pgdata: diff --git a/dal/backends.py b/dal/backends.py new file mode 100644 index 0000000..b28e3af --- /dev/null +++ b/dal/backends.py @@ -0,0 +1,73 @@ +""" +LDAP authentication with ldap3, replacing django-auth-ldap and python-ldap. + +Same behaviour as the old django-auth-ldap setup: the uid is searched (as +the admin DN) in every base of LDAPSEARCH, exactly one match is required, +the user logs in with their own DN, and a local Django user mirrors the +names and email from LDAP (AUTH_LDAP_USER_ATTR_MAP). +""" +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from ldap3.core.exceptions import LDAPException +from ldap3.utils.conv import escape_filter_chars + +from .ungleich_ldap import LdapManager + +logger = logging.getLogger(__name__) + + +class LdapBackend(ModelBackend): + + def authenticate(self, request, username=None, password=None, **kwargs): + # An empty password is an anonymous bind in LDAP and would succeed. + if not username or not password: + return None + + ldap_manager = LdapManager() + try: + entry = self._find_user(ldap_manager, username) + if entry is None: + return None + conn = ldap_manager.get_conn(user=entry.entry_dn, password=password) + try: + if not conn.bind(): + return None + finally: + conn.unbind() + except LDAPException as exc: + logger.error("LDAP authentication for %s failed: %s", username, exc) + return None + + user = self._sync_user(username, entry) + return user if self.user_can_authenticate(user) else None + + def _find_user(self, ldap_manager, username): + attributes = list(settings.AUTH_LDAP_USER_ATTR_MAP.values()) + search_filter = "(uid={})".format(escape_filter_chars(username)) + matches = [] + conn = ldap_manager.get_admin_conn() + try: + for base in settings.LDAP_SEARCH_BASES: + conn.search(base, search_filter, attributes=attributes) + matches.extend(conn.entries) + finally: + conn.unbind() + if len(matches) != 1: + if matches: + logger.warning("uid %s matched %d entries; refusing login", + username, len(matches)) + return None + return matches[0] + + def _sync_user(self, username, entry): + user, created = get_user_model().objects.get_or_create(username=username) + for field, attr in settings.AUTH_LDAP_USER_ATTR_MAP.items(): + value = entry[attr].value if attr in entry else None + setattr(user, field, value if isinstance(value, str) else "") + if created: + user.set_unusable_password() + user.save() + return user diff --git a/dal/env.sample b/dal/env.sample deleted file mode 100644 index a404f03..0000000 --- a/dal/env.sample +++ /dev/null @@ -1,37 +0,0 @@ -# Create .env to be loaded automatically -# XXX: Salvaged from production, don't ask me why some settings are duplicated. - -LDAPSERVER="" -LDAPSEARCHUSER="" -LDAPSEARCHUSERPASSWORD="" - -LDAP_CUSTOMER_DN="" -LDAP_USERS_DN="" - -LDAP_SERVER="" -LDAP_ADMIN_DN="" -LDAP_ADMIN_PASSWORD="" - -# Space separated list of search bases for users -LDAPSEARCH="" -LDAPCREATE="" -LDAP_CUSTOMER_DN="" -LDAP_DEFAULT_START_UID= -LDAP_CUSTOMER_GROUP_ID= -ENTIRE_SEARCH_BASE="" - -#LDAP_USE_TLS=True -SECRET_KEY="" -ALLOWED_HOSTS="localhost:8000,localhost" -DEBUG=True -ENABLE_DEBUG_LOG=True -MODULES_TO_LOG=django_auth_ldap,dal,django -EMAIL_FROM_ADDRESS="" - -#Otp -ALLOWED_REALMS='' -OTPSERVER='' -ADMIN_SEED='' -ADMIN_NAME='' -ADMIN_REALM='' -USER_REALM='' diff --git a/dal/forms.py b/dal/forms.py index 972329b..ca74187 100644 --- a/dal/forms.py +++ b/dal/forms.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.auth import authenticate -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class LoginForm(forms.Form): diff --git a/dal/health.py b/dal/health.py new file mode 100644 index 0000000..7a8d667 --- /dev/null +++ b/dal/health.py @@ -0,0 +1,59 @@ +""" +Health endpoints for Kubernetes probes. + +Implemented as the first middleware so probes are answered before +ALLOWED_HOSTS validation (probes use the pod IP as Host) and before any +HTTPS redirect. + + /healthz liveness: the process serves requests; no dependencies. + /readyz readiness: the database answers and an LDAP server accepts the + admin bind. Returns 503 with the failing checks otherwise. +""" +import logging + +from django.db import connection +from django.http import JsonResponse + +from .ungleich_ldap import LdapManager + +logger = logging.getLogger(__name__) + + +def check_database(): + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + + +def check_ldap(): + LdapManager().get_admin_conn().unbind() + + +READINESS_CHECKS = {"database": check_database, "ldap": check_ldap} + + +class HealthCheckMiddleware: + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path == "/healthz": + return JsonResponse({"status": "ok"}) + if request.path == "/readyz": + return self.readiness() + return self.get_response(request) + + def readiness(self): + results = {} + for name, check in READINESS_CHECKS.items(): + try: + check() + results[name] = "ok" + except Exception as exc: + logger.warning("readiness check %s failed: %s", name, exc) + results[name] = "failed" + ready = all(v == "ok" for v in results.values()) + return JsonResponse( + {"status": "ok" if ready else "unavailable", "checks": results}, + status=200 if ready else 503, + ) diff --git a/ungleichuser/ungleichuser/__init__.py b/dal/management/__init__.py similarity index 100% rename from ungleichuser/ungleichuser/__init__.py rename to dal/management/__init__.py diff --git a/dal/management/commands/__init__.py b/dal/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dal/management/commands/migrate_locked.py b/dal/management/commands/migrate_locked.py new file mode 100644 index 0000000..7a9f299 --- /dev/null +++ b/dal/management/commands/migrate_locked.py @@ -0,0 +1,31 @@ +""" +`migrate`, serialized across pods with a PostgreSQL advisory lock. + +Kubernetes may start several pods at once (first deploy, scale-up), and each +runs migrations in its init container. Django does not guard against +concurrent `migrate`, so with PostgreSQL the first pod takes the lock and +migrates while the others wait, then find nothing left to do. On SQLite +(single replica) this is plain `migrate`. +""" +from django.core.management import call_command +from django.core.management.base import BaseCommand +from django.db import connection + +# Arbitrary constant identifying this app's migration lock. +LOCK_ID = 0x756E676C # "ungl" + + +class Command(BaseCommand): + help = "Run migrate while holding a PostgreSQL advisory lock." + + def handle(self, *args, **options): + if connection.vendor != "postgresql": + call_command("migrate", interactive=False, verbosity=options["verbosity"]) + return + with connection.cursor() as cursor: + self.stdout.write("Waiting for migration lock...") + cursor.execute("SELECT pg_advisory_lock(%s)", [LOCK_ID]) + try: + call_command("migrate", interactive=False, verbosity=options["verbosity"]) + finally: + cursor.execute("SELECT pg_advisory_unlock(%s)", [LOCK_ID]) diff --git a/dal/migrations/0002_pendingregistration.py b/dal/migrations/0002_pendingregistration.py new file mode 100644 index 0000000..1e687ba --- /dev/null +++ b/dal/migrations/0002_pendingregistration.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.28 on 2026-11-06 12:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dal', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PendingRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=64, unique=True)), + ('username', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=254)), + ('firstname', models.CharField(max_length=255)), + ('lastname', models.CharField(max_length=255)), + ('password_hash', models.CharField(max_length=255)), + ('creation', models.BigIntegerField()), + ], + ), + ] diff --git a/dal/models.py b/dal/models.py index 676b103..eeae4df 100644 --- a/dal/models.py +++ b/dal/models.py @@ -17,6 +17,22 @@ class ResetToken(models.Model): creation = models.BigIntegerField() +class PendingRegistration(models.Model): + """A sign-up waiting for its email to be confirmed. + + Only the {SSHA} hash of the password is kept, and the activation link + carries nothing but the random token. + """ + token = models.CharField(max_length=64, unique=True) + username = models.CharField(max_length=100) + email = models.EmailField(max_length=254) + firstname = models.CharField(max_length=255) + lastname = models.CharField(max_length=255) + password_hash = models.CharField(max_length=255) + # creation time in epoch (UTC), like ResetToken + creation = models.BigIntegerField() + + def get_validation_slug(): return make_password(None) diff --git a/dal/settings.py b/dal/settings.py index e39373f..c5d7176 100644 --- a/dal/settings.py +++ b/dal/settings.py @@ -1,48 +1,49 @@ """ -Django settings for dal project. +Django settings for the ungleich account webapp (dal). -Generated by 'django-admin startproject' using Django 1.10.7. - -For more information on this file, see -https://docs.djangoproject.com/en/1.10/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.10/ref/settings/ +All deployment-specific values come from environment variables (or a +.env file in development), read here and nowhere else. See .env.example +and docker.md for the full list, which ones are required, and which are +secrets. """ import os from decouple import config, Csv -import ldap -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion # LDAP setup -LDAP_SERVER = config('LDAP_SERVER') -AUTH_LDAP_SERVER_URI = config('LDAPSERVER') +# Space separated list of LDAP URIs; the first reachable one is used. +LDAP_SERVERS = config('LDAPSERVER').split() LDAP_ADMIN_DN = config('LDAP_ADMIN_DN') LDAP_ADMIN_PASSWORD = config('LDAP_ADMIN_PASSWORD') -AUTH_LDAP_BIND_DN = LDAP_ADMIN_DN -AUTH_LDAP_BIND_PASSWORD = LDAP_ADMIN_PASSWORD -AUTH_LDAP_SERVER = AUTH_LDAP_SERVER_URI -LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN') -LDAP_USERS_DN = config('LDAP_USERS_DN') -LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', cast=int) +# The directory layout defaults to production's: customers in ou=customer, +# staff in ou=users, both below one base. Another tree (e.g. a test +# server) usually only needs ENTIRE_SEARCH_BASE. +# Usernames and uidNumbers are unique below ENTIRE_SEARCH_BASE. +ENTIRE_SEARCH_BASE = config('ENTIRE_SEARCH_BASE', default='dc=ungleich,dc=ch') +# New registrations are created here. +LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN', default='ou=customer,' + ENTIRE_SEARCH_BASE) +LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', default=10004, cast=int) -LDAP_MAX_UID_FILE_PATH = config( - 'LDAP_MAX_UID_FILE_PATH', - default=os.path.join(os.path.abspath( - os.path.dirname(__file__)), 'ldap_max_uid_file' - ) -) -LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', cast=int) +LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', default=10000, cast=int) +# objectClasses of new customer entries (space separated). ldapPublicKey +# needs the openssh-lpk schema, which production has. +LDAP_USER_OBJECT_CLASSES = config( + 'LDAP_USER_OBJECT_CLASSES', default='inetOrgPerson posixAccount ldapPublicKey').split() -# Search union over OUs -search_base = config('LDAPSEARCH').split() -search_base_ldap = [ LDAPSearch(x, ldap.SCOPE_SUBTREE, "(uid=%(user)s)") for x in search_base ] -AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*search_base_ldap) +# Users log in from any of these (space separated) search bases. +LDAP_SEARCH_BASES = config( + 'LDAPSEARCH', + default='ou=customer,{0} ou=users,{0}'.format(ENTIRE_SEARCH_BASE)).split() -AUTH_LDAP_START_TLS = config('LDAP_USE_TLS', default=False, cast=bool) +# StartTLS on every connection (use ldaps:// URIs for implicit TLS). +LDAP_USE_TLS = config('LDAP_USE_TLS', default=False, cast=bool) +# Verify LDAP server certificates (ldaps:// and StartTLS). LDAP_CA_FILE +# adds a CA bundle for servers with a private CA; the system store is used +# otherwise. Only disable verification for local experiments. +LDAP_TLS_VERIFY = config('LDAP_TLS_VERIFY', default=True, cast=bool) +LDAP_CA_FILE = config('LDAP_CA_FILE', default='') BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -50,11 +51,39 @@ DEBUG = config('DEBUG', default=False, cast=bool) EMAIL_FROM_ADDRESS = config('EMAIL_FROM_ADDRESS') -EMAIL_HOST = config("EMAIL_HOST", default="localhost") -EMAIL_PORT = config("EMAIL_PORT", default=25, cast=int) -EMAIL_USE_TLS = config("EMAIL_USE_TLS", default=True, cast=bool) +MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": config("EMAIL_HOST", default="localhost"), + "port": config("EMAIL_PORT", default=25, cast=int), + "username": config("EMAIL_HOST_USER", default=""), + "password": config("EMAIL_HOST_PASSWORD", default=""), + "use_tls": config("EMAIL_USE_TLS", default=True, cast=bool), + "timeout": config("EMAIL_TIMEOUT", default=10, cast=int), + }, + }, +} -ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv()) +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='account.ungleich.ch', cast=Csv()) + +# --- HTTPS / proxy ----------------------------------------------------------- +# Behind a TLS-terminating proxy (e.g. a Kubernetes ingress) Django sees plain +# HTTP. Only enable this if the proxy always sets X-Forwarded-Proto. +if config('TRUST_X_FORWARDED_PROTO', default=False, cast=bool): + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# Full origins (scheme included). Defaults to https:// plus each +# ALLOWED_HOSTS entry, which is what a site behind an HTTPS ingress needs. +CSRF_TRUSTED_ORIGINS = config( + 'CSRF_TRUSTED_ORIGINS', + default=','.join('https://' + h.lstrip('.') for h in ALLOWED_HOSTS if h != '*'), + cast=Csv()) + +SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=not DEBUG, cast=bool) +CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=not DEBUG, cast=bool) +SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=False, cast=bool) +SECURE_HSTS_SECONDS = config('SECURE_HSTS_SECONDS', default=0, cast=int) INSTALLED_APPS = [ 'django.contrib.admin', @@ -69,7 +98,10 @@ INSTALLED_APPS = [ ] MIDDLEWARE = [ + # First: answers /healthz and /readyz before host checks and redirects. + 'dal.health.HealthCheckMiddleware', 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -79,7 +111,7 @@ MIDDLEWARE = [ ] AUTHENTICATION_BACKENDS = ( - 'django_auth_ldap.backend.LDAPBackend', + 'dal.backends.LdapBackend', ) @@ -131,8 +163,6 @@ TIME_ZONE = 'UTC' USE_I18N = True -USE_L10N = True - USE_TZ = True @@ -140,76 +170,106 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.10/howto/static-files/ STATIC_URL = '/static/' +# collectstatic target; served by WhiteNoise (gzip compressed). +# Not the hashed "Manifest" storage: the bundled vendor CSS references +# source maps that are not shipped, which makes hashing fail. +STATIC_ROOT = config('STATIC_ROOT', default=os.path.join(BASE_DIR, 'static')) -############################# To be fixed - -STATIC_ROOT= os.path.join(BASE_DIR, 'static/') +STORAGES = { + 'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, + 'staticfiles': { + 'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', + }, +} # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), +# PostgreSQL when DATABASE_HOST is set (needed for more than one replica), +# otherwise SQLite at SQLITE_PATH. +if config('DATABASE_HOST', default=''): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'HOST': config('DATABASE_HOST'), + 'PORT': config('DATABASE_PORT', default=5432, cast=int), + 'NAME': config('DATABASE_NAME', default='ungleich_user'), + 'USER': config('DATABASE_USER', default='ungleich_user'), + 'PASSWORD': config('DATABASE_PASSWORD', default=''), + 'CONN_MAX_AGE': config('DATABASE_CONN_MAX_AGE', default=60, cast=int), + 'CONN_HEALTH_CHECKS': True, + 'OPTIONS': { + 'sslmode': config('DATABASE_SSLMODE', default='prefer'), + }, + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': config('SQLITE_PATH', default=os.path.join(BASE_DIR, 'db.sqlite3')), + } } -} SECRET_KEY = config('SECRET_KEY') +# Keep 32-bit ids: existing tables were created with AutoField. +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + AUTH_LDAP_USER_ATTR_MAP = { "first_name": "givenName", "last_name": "sn", "email": "mail" } -ENTIRE_SEARCH_BASE = config("ENTIRE_SEARCH_BASE") +# Everything goes to stdout/stderr (12-factor / Kubernetes friendly). +LOG_LEVEL = config('LOG_LEVEL', default='INFO').upper() LOGGING = { - 'disable_existing_loggers': False, 'version': 1, + 'disable_existing_loggers': False, 'formatters': { 'standard': { 'format': '%(asctime)s %(levelname)s %(name)s %(funcName)s %(lineno)d: %(message)s' } }, 'handlers': { - 'default': { - 'level': 'DEBUG', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': 'logs/debug.log', - 'maxBytes': 1024*1024*5, - 'backupCount': 10, - 'formatter': 'standard', - }, 'console': { 'class': 'logging.StreamHandler', + 'formatter': 'standard', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': LOG_LEVEL, + }, + 'loggers': { + # Keep Django's own request/DB chatter at INFO unless asked for. + 'django': { + 'level': config('DJANGO_LOG_LEVEL', default='INFO').upper(), }, }, } -if config('ENABLE_DEBUG_LOG', default=False, cast=bool): - loggers_dict = {} - modules_to_log_list = config( - 'MODULES_TO_LOG', default='django', cast=Csv() - ) - for custom_module in modules_to_log_list: - logger_item = { - custom_module: { - 'handlers': ['default'], - 'level': 'INFO', - 'propagate': True - } - } - loggers_dict.update(logger_item) +# ldap3 protocol logging is very verbose; opt in with LDAP3_LOG=1. +if config('LDAP3_LOG', default=False, cast=bool): + from ldap3.utils.log import set_library_log_detail_level, BASIC + set_library_log_detail_level(BASIC) - LOGGING['loggers'] = loggers_dict - - if 'ldap3' in modules_to_log_list: - from ldap3.utils.log import ( - set_library_log_detail_level, OFF, BASIC, NETWORK, EXTENDED - ) - set_library_log_detail_level(BASIC) +# OTP server that stores per-user TOTP seeds, accessed with an admin TOTP. +# Optional: without OTPSERVER, new accounts get no OTP seed and the seed +# pages report that OTP is not configured. +OTP_SERVER = config('OTPSERVER', default='') +OTP_ADMIN_SEED = config('ADMIN_SEED', default='') +OTP_ADMIN_NAME = config('ADMIN_NAME', default='') +OTP_ADMIN_REALM = config('ADMIN_REALM', default='') +OTP_USER_REALM = config('USER_REALM', default='') +OTP_ALLOWED_REALMS = config('ALLOWED_REALMS', default='', cast=Csv()) +if OTP_SERVER and not all([OTP_ADMIN_SEED, OTP_ADMIN_NAME, OTP_ADMIN_REALM, OTP_USER_REALM]): + from django.core.exceptions import ImproperlyConfigured + raise ImproperlyConfigured( + 'OTPSERVER is set, so ADMIN_SEED, ADMIN_NAME, ADMIN_REALM and USER_REALM are required too') +OTP_TIMEOUT = config('OTP_TIMEOUT', default=10, cast=int) # seconds REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( diff --git a/dal/templates/base.html b/dal/templates/base.html index 030afb5..d01d835 100644 --- a/dal/templates/base.html +++ b/dal/templates/base.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} diff --git a/dal/templates/base_short.html b/dal/templates/base_short.html index dd9e131..a60825c 100644 --- a/dal/templates/base_short.html +++ b/dal/templates/base_short.html @@ -1,4 +1,4 @@ -{% load staticfiles i18n %} +{% load static i18n %} diff --git a/dal/templates/changeddata.html b/dal/templates/changeddata.html index 3764ac3..580c95e 100644 --- a/dal/templates/changeddata.html +++ b/dal/templates/changeddata.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %}