diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 9e2a681..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -.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 deleted file mode 100644 index a61c190..0000000 --- a/.env.example +++ /dev/null @@ -1,100 +0,0 @@ -# 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 2af37cc..38b9c32 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,4 @@ secret-key *.mo venv/ - -# dev LDAP CA certificate, regenerated on every start -dev/ldap/export/ +dal/ldap_max_uid_file diff --git a/Dockerfile b/Dockerfile index 05f7a15..e3ec123 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,16 @@ -# 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 +FROM python:3.10.0-alpine3.15 -FROM python:3.15-slim-trixie +WORKDIR /usr/src/app -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 +RUN apk add --update --no-cache\ + build-base \ + openldap-dev\ + python3-dev\ + && rm -rf /var/cache/apk/* -# 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 +# FIX https://github.com/python-ldap/python-ldap/issues/432 +RUN echo 'INPUT ( libldap.so )' > /usr/lib/libldap_r.so -# 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"] +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY ungleichuser/ . diff --git a/README.md b/README.md index d8efcc0..0a1cb2d 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,83 @@ -# ungleich account webapp +# Ungleich Account Management Webapp -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. +This service runs on [account.ungleich.ch](https://account.ungleich.ch/) and +allows customers manage their ungleich account (register, edit mail & password, +...). -Django 6.1 on Python 3.15. LDAP access goes through -[ldap3](https://ldap3.readthedocs.io/) only, with no C extensions. +## Status v2 -## Development +* See below ungleichuser/ +* register view created +* Not solved: + * email verification *before* user is created +* Solved + * Finding highest uid, increasing by 1 -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. +## 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 -```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 +`` -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`. +## Functionality -### Tests +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? -```sh -.venv/bin/python manage.py test dal -``` +## Parameters -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. +Via environment variables: -## Configuration +* 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 -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. -## Deployment +## Views -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). +### Register view -## Layout +Form: [get] -| 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) | +* username +* password1 +* password2 + +Post receiver: [post] + +* E-Mail verification + +E-Mail verify: [get] + +* Create user + +### Password reset view + +* captcha +* username or email diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index bb29079..0000000 --- a/compose.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# 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 deleted file mode 100644 index b28e3af..0000000 --- a/dal/backends.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -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 new file mode 100644 index 0000000..a404f03 --- /dev/null +++ b/dal/env.sample @@ -0,0 +1,37 @@ +# 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 ca74187..972329b 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 gettext_lazy as _ +from django.utils.translation import ugettext_lazy as _ class LoginForm(forms.Form): diff --git a/dal/health.py b/dal/health.py deleted file mode 100644 index 7a8d667..0000000 --- a/dal/health.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -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/dal/management/commands/__init__.py b/dal/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dal/management/commands/migrate_locked.py b/dal/management/commands/migrate_locked.py deleted file mode 100644 index 7a9f299..0000000 --- a/dal/management/commands/migrate_locked.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -`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 deleted file mode 100644 index 1e687ba..0000000 --- a/dal/migrations/0002_pendingregistration.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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 eeae4df..676b103 100644 --- a/dal/models.py +++ b/dal/models.py @@ -17,22 +17,6 @@ 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 c5d7176..e39373f 100644 --- a/dal/settings.py +++ b/dal/settings.py @@ -1,49 +1,48 @@ """ -Django settings for the ungleich account webapp (dal). +Django settings for dal project. -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. +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/ """ import os from decouple import config, Csv +import ldap +from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion # LDAP setup -# Space separated list of LDAP URIs; the first reachable one is used. -LDAP_SERVERS = config('LDAPSERVER').split() +LDAP_SERVER = config('LDAP_SERVER') +AUTH_LDAP_SERVER_URI = config('LDAPSERVER') 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 -# 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_CUSTOMER_DN = config('LDAP_CUSTOMER_DN') +LDAP_USERS_DN = config('LDAP_USERS_DN') +LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', 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() +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) -# 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() +# 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) -# 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='') +AUTH_LDAP_START_TLS = config('LDAP_USE_TLS', default=False, cast=bool) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -51,39 +50,11 @@ DEBUG = config('DEBUG', default=False, cast=bool) EMAIL_FROM_ADDRESS = config('EMAIL_FROM_ADDRESS') -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), - }, - }, -} +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) -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) +ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv()) INSTALLED_APPS = [ 'django.contrib.admin', @@ -98,10 +69,7 @@ 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', @@ -111,7 +79,7 @@ MIDDLEWARE = [ ] AUTHENTICATION_BACKENDS = ( - 'dal.backends.LdapBackend', + 'django_auth_ldap.backend.LDAPBackend', ) @@ -163,6 +131,8 @@ TIME_ZONE = 'UTC' USE_I18N = True +USE_L10N = True + USE_TZ = True @@ -170,106 +140,76 @@ 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')) -STORAGES = { - 'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'}, - 'staticfiles': { - 'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', - }, -} +############################# To be fixed + +STATIC_ROOT= os.path.join(BASE_DIR, 'static/') # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases -# 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')), - } +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 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" } -# Everything goes to stdout/stderr (12-factor / Kubernetes friendly). -LOG_LEVEL = config('LOG_LEVEL', default='INFO').upper() +ENTIRE_SEARCH_BASE = config("ENTIRE_SEARCH_BASE") LOGGING = { - 'version': 1, 'disable_existing_loggers': False, + 'version': 1, 'formatters': { 'standard': { 'format': '%(asctime)s %(levelname)s %(name)s %(funcName)s %(lineno)d: %(message)s' } }, 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + 'default': { + 'level': 'DEBUG', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': 'logs/debug.log', + 'maxBytes': 1024*1024*5, + 'backupCount': 10, '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(), + 'console': { + 'class': 'logging.StreamHandler', }, }, } -# 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) +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) + 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 d01d835..030afb5 100644 --- a/dal/templates/base.html +++ b/dal/templates/base.html @@ -1,4 +1,4 @@ -{% load static %} +{% load staticfiles %} diff --git a/dal/templates/base_short.html b/dal/templates/base_short.html index a60825c..dd9e131 100644 --- a/dal/templates/base_short.html +++ b/dal/templates/base_short.html @@ -1,4 +1,4 @@ -{% load static i18n %} +{% load staticfiles i18n %} diff --git a/dal/templates/changeddata.html b/dal/templates/changeddata.html index 580c95e..3764ac3 100644 --- a/dal/templates/changeddata.html +++ b/dal/templates/changeddata.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Success! diff --git a/dal/templates/changedpassword.html b/dal/templates/changedpassword.html index 26251d0..d9ba591 100644 --- a/dal/templates/changedpassword.html +++ b/dal/templates/changedpassword.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Password Updated. diff --git a/dal/templates/changepassword.html b/dal/templates/changepassword.html index f7b66c8..c6f61e6 100644 --- a/dal/templates/changepassword.html +++ b/dal/templates/changepassword.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Changing the password for {{user}} diff --git a/dal/templates/changeuserdata.html b/dal/templates/changeuserdata.html index 7306b3c..8878ca2 100644 --- a/dal/templates/changeuserdata.html +++ b/dal/templates/changeuserdata.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Changing user data for {{user}} diff --git a/dal/templates/confirm_email.html b/dal/templates/confirm_email.html index c48061e..729af28 100644 --- a/dal/templates/confirm_email.html +++ b/dal/templates/confirm_email.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Verify your email. diff --git a/dal/templates/deleteaccount.html b/dal/templates/deleteaccount.html index 8a075e7..3c74052 100644 --- a/dal/templates/deleteaccount.html +++ b/dal/templates/deleteaccount.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Deleting Account diff --git a/dal/templates/deleteduser.html b/dal/templates/deleteduser.html index 4145a38..d0c4330 100644 --- a/dal/templates/deleteduser.html +++ b/dal/templates/deleteduser.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} Deleted user {{user}} diff --git a/dal/templates/error.html b/dal/templates/error.html index aee7a36..6726430 100644 --- a/dal/templates/error.html +++ b/dal/templates/error.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n static bootstrap3 %} +{% load i18n staticfiles bootstrap3 %} {% block title %} An error has occurred! diff --git a/dal/templates/hosting_login.html b/dal/templates/hosting_login.html index e4b3660..8a52ae1 100644 --- a/dal/templates/hosting_login.html +++ b/dal/templates/hosting_login.html @@ -1,5 +1,5 @@ {% extends "hosting/base_short.html" %} -{% load i18n static %} +{% load i18n staticfiles %} {% block navbar %} {% include 'hosting/includes/_navbar_transparent.html' %} diff --git a/dal/templates/includes/_header.html b/dal/templates/includes/_header.html index d4c6a82..6f9b4a5 100644 --- a/dal/templates/includes/_header.html +++ b/dal/templates/includes/_header.html @@ -1,4 +1,4 @@ -{% load static %} +{% load staticfiles %}
diff --git a/dal/templates/includes/_navbar.html b/dal/templates/includes/_navbar.html index 694c67a..44b1bf5 100644 --- a/dal/templates/includes/_navbar.html +++ b/dal/templates/includes/_navbar.html @@ -1,4 +1,4 @@ -{% load static %} +{% load staticfiles %}