Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

89 changed files with 1237 additions and 2791 deletions

View file

@ -1,14 +0,0 @@
.git
.venv
venv
.env
*.sqlite3
static/
**/__pycache__
**/*.pyc
dal/tests
dev
deploy
docker-compose*.yml
compose.yaml
*.md

View file

@ -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

4
.gitignore vendored
View file

@ -39,6 +39,4 @@ secret-key
*.mo
venv/
# dev LDAP CA certificate, regenerated on every start
dev/ldap/export/
dal/ldap_max_uid_file

View file

@ -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/ .

118
README.md
View file

@ -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

View file

@ -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:

View file

@ -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

37
dal/env.sample Normal file
View file

@ -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=''

View file

@ -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):

View file

@ -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,
)

View file

@ -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])

View file

@ -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()),
],
),
]

View file

@ -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)

View file

@ -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': (

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<!DOCTYPE html>
<html lang="en">

View file

@ -1,4 +1,4 @@
{% load static i18n %} <!--cms_tags %}-->
{% load staticfiles i18n %} <!--cms_tags %}-->
<!DOCTYPE html>
<html lang="en">

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Success! </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Password Updated. </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Changing the password for {{user}} </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Changing user data for {{user}} </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Verify your email. </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Deleting Account </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Deleted user {{user}} </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title>An error has occurred!</title>

View file

@ -1,5 +1,5 @@
{% extends "hosting/base_short.html" %}
{% load i18n static %}
{% load i18n staticfiles %}
{% block navbar %}
{% include 'hosting/includes/_navbar_transparent.html' %}

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<a name="about"></a>
<div class="intro-header">
<div class="container">

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<nav class="navbar navbar-default navbar-fixed-top topnav" role="navigation">

View file

@ -0,0 +1,49 @@
{% load staticfiles i18n custom_tags %}
<!-- Navigation -->
<nav class="navbar navbar-default navbar-fixed-top topnav" role="navigation">
<div class="topnav">
<!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header">
{% if request.user.is_authenticated %}
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">{% trans "Toggle navigation" %}</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
{% endif %}
<a class="navbar-brand topnav" href="{% url 'datacenterlight:index' %}"><img src="{% static 'datacenterlight/img/logo_black.svg' %}"></a>
</div>
{% if request.user.is_authenticated %}
<!-- Collect the nav links, forms, and other content for toggling -->
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav navbar-right">
<li>
<a href="{% url 'hosting:dashboard' %}">
<i class="fa fa-fw fa-server" aria-hidden="true"></i>&nbsp;&nbsp;{% trans "Dashboard"%}
</a>
</li>
<li class="dropdown highlights-dropdown">
<a class="dropdown-toggle" role="button" data-toggle="dropdown" href="#">
<i class="fa fa-fw fa-user"></i>&nbsp;&nbsp;{{request.user.name}}&nbsp;<span class="fa fa-fw fa-caret-down"></span>
</a>
<ul id="g-account-menu" class="dropdown-menu" role="menu">
<li><a href="{% url 'hosting:logout' %}">{% trans "Logout"%}</a></li>
</ul>
</li>
<li>
{% get_current_language as LANGUAGE_CODE %}
{% if LANGUAGE_CODE == 'en-us'%}
<a href="{% change_lang 'de' %}"><i class="fa fa-fw fa-globe" aria-hidden="true"></i>&nbsp;&nbsp;Deutsch</a>
{% else %}
<a href="{% change_lang 'en-us' %}"><i class="fa fa-fw fa-globe" aria-hidden="true"></i>&nbsp;&nbsp;English</a>
{% endif %}
</li>
</ul>
</div>
{% endif %}
<!-- /.navbar-collapse -->
</div>
<!-- /.container -->
</nav>

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<a name="our"></a>
<div class="content-section-b">

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<a name="price"></a>
<div class="content-section-b">
<div class="container-fluid pricing-container">

View file

@ -1,4 +1,4 @@
{% load static %}
{% load staticfiles %}
<a name="your"></a>
<div class="content-section-a" id="own">

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title>ungleich</title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title>Login failed!</title>

View file

@ -1,6 +1,6 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> You must be logged in to access this page </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Register an user at ungleich </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Password reset </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Set new password for {{user}} </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title>Options for {{user}}</title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> Reset request processed and confirmation email sent </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title> User {{ user }} created. </title>

View file

@ -1,5 +1,5 @@
{% extends "base_short.html" %}
{% load i18n static bootstrap3 %}
{% load i18n staticfiles bootstrap3 %}
{% block title %}
<title>Options for {{user}}</title>

View file

View file

@ -1,132 +0,0 @@
"""
Shared helpers for the end-to-end tests.
These tests drive the real views over HTTP (Django test client) against a
real OpenLDAP server, started with:
docker compose -f docker-compose.dev.yml up -d --build
They are deliberately black-box: they assert on what a user sees (templates,
emails, status codes) and on what ends up in LDAP, so they keep passing while
the internals are rewritten during the upgrade.
"""
import re
import uuid
from unittest import mock
import ldap3
from django.conf import settings
from django.core import mail
from django.test import TestCase
from dal.ungleich_ldap import LdapManager
_ldap_checked = False
# The real test server; tests may override settings.LDAP_SERVERS.
TEST_LDAP_URI = settings.LDAP_SERVERS[0]
def admin_conn():
server = ldap3.Server(TEST_LDAP_URI)
return ldap3.Connection(
server, settings.LDAP_ADMIN_DN, settings.LDAP_ADMIN_PASSWORD,
auto_bind=True, raise_exceptions=False,
)
def ensure_ldap_up():
global _ldap_checked
if _ldap_checked:
return
try:
admin_conn().unbind()
except Exception as exc: # pragma: no cover - environment problem
raise RuntimeError(
"Test LDAP not reachable at {}. Start it with: docker compose -f "
"docker-compose.dev.yml up -d --build ({})".format(
TEST_LDAP_URI, exc)
)
_ldap_checked = True
def user_dn(uid, ou="customer"):
"""DN of a user entry; the tree comes from settings (ENTIRE_SEARCH_BASE)."""
return "uid={},ou={},{}".format(uid, ou, settings.ENTIRE_SEARCH_BASE)
def unique_uid(prefix="t"):
# Matches the app's username rule: lowercase letters, digits, - and _
return "{}-{}".format(prefix, uuid.uuid4().hex[:10])
class LdapTestCase(TestCase):
"""Base class: unique users per test, cleaned up afterwards."""
def setUp(self):
ensure_ldap_up()
self._created_dns = []
# OTP server is external; never hit the network from tests.
self.otp_post = mock.patch("dal.views.requests.post").start()
self.otp_post.return_value = mock.Mock(status_code=201, text="{}")
self.otp_get = mock.patch("dal.views.requests.get").start()
self.otp_get.return_value = mock.Mock(status_code=200, text="[]")
self.addCleanup(mock.patch.stopall)
def tearDown(self):
conn = admin_conn()
for dn in self._created_dns:
conn.delete(dn)
conn.unbind()
# LDAP helpers ---------------------------------------------------------
def make_ldap_user(self, uid=None, password="initial-pass-123",
ou="customer", email=None):
"""Create a user directly in LDAP, the same way the app does."""
uid = uid or unique_uid()
dn = user_dn(uid, ou)
conn = admin_conn()
ok = conn.add(dn, settings.LDAP_USER_OBJECT_CLASSES, {
"uid": uid, "cn": uid, "sn": "Tester", "givenName": "Tina",
"mail": email or "{}@example.com".format(uid),
"uidNumber": str(30000 + int(uuid.uuid4().int % 100000)),
"gidNumber": str(settings.LDAP_CUSTOMER_GROUP_ID), "homeDirectory": "/home/" + uid,
"userPassword": LdapManager()._ssha_password(password.encode()),
})
conn.unbind()
self.assertTrue(ok, "could not seed LDAP user {}".format(dn))
self._created_dns.append(dn)
return uid
def track(self, uid, ou="customer"):
"""Register a user created by the app for cleanup."""
self._created_dns.append(user_dn(uid, ou))
def ldap_entry(self, uid, attributes=("*",)):
conn = admin_conn()
conn.search(settings.ENTIRE_SEARCH_BASE, "(uid={})".format(
ldap3.utils.conv.escape_filter_chars(uid)), attributes=list(attributes))
entries = list(conn.entries)
conn.unbind()
return entries[0] if entries else None
def can_bind(self, uid, password, ou="customer"):
server = ldap3.Server(TEST_LDAP_URI)
conn = ldap3.Connection(
server, user_dn(uid, ou), password)
ok = conn.bind()
conn.unbind()
return ok
# HTTP helpers ---------------------------------------------------------
def login(self, uid, password):
self.assertTrue(self.client.login(username=uid, password=password),
"login failed for {}".format(uid))
def last_mail_link(self):
self.assertTrue(mail.outbox, "no email was sent")
urls = re.findall(r"https?://testserver(/[^\s\"'<>]+)", mail.outbox[-1].body)
self.assertTrue(urls, "no link in email: " + mail.outbox[-1].body)
return urls[-1]

View file

@ -1,628 +0,0 @@
"""
End-to-end behaviour of the account webapp against a real LDAP server.
Several tests guard against security bugs found in the 2026 upgrade audit
(account takeover via /reset/, LDAP filter injection, passwords in links,
duplicate usernames across subtrees).
"""
from django.core import mail
from unittest import skipUnless
from django.conf import settings
from django.test import override_settings
from .base import TEST_LDAP_URI, LdapTestCase, unique_uid
REGISTER = "/register/"
RESET = "/resetpassword/"
class LoginTests(LdapTestCase):
def test_landing_page_renders(self):
resp = self.client.get("/")
self.assertEqual(resp.status_code, 200)
self.assertTemplateUsed(resp, "landing.html")
def test_customer_can_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
self.assertIn("_auth_user_id", self.client.session)
def test_staff_in_ou_users_can_log_in(self):
uid = self.make_ldap_user(password="staff-pass-123", ou="users")
resp = self.client.post("/index/", {"username": uid, "password": "staff-pass-123"})
self.assertTemplateUsed(resp, "useroptions.html")
def test_wrong_password_is_rejected(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/index/", {"username": uid, "password": "nope"})
self.assertNotIn("_auth_user_id", self.client.session)
self.assertContains(resp, "incorrect")
def test_empty_password_is_rejected(self):
"""An empty password is an anonymous LDAP bind, which succeeds."""
uid = self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username=uid, password=""))
resp = self.client.post("/index/", {"username": uid, "password": ""})
self.assertNotIn("_auth_user_id", self.client.session)
def test_unknown_user_is_rejected(self):
self.assertFalse(self.client.login(username="nobody-here", password="x"))
def test_filter_characters_in_username_do_not_match(self):
self.make_ldap_user(password="cust-pass-123")
self.assertFalse(self.client.login(username="*", password="cust-pass-123"))
def test_uid_in_two_subtrees_cannot_log_in(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.make_ldap_user(uid=uid, password="cust-pass-123", ou="users")
self.assertFalse(self.client.login(username=uid, password="cust-pass-123"))
def test_login_mirrors_ldap_attributes(self):
from django.contrib.auth.models import User
uid = self.make_ldap_user(password="cust-pass-123", email="mirror@example.com")
self.login(uid, "cust-pass-123")
user = User.objects.get(username=uid)
self.assertEqual((user.first_name, user.last_name, user.email),
("Tina", "Tester", "mirror@example.com"))
self.assertFalse(user.has_usable_password())
def test_logged_in_user_gets_options_page(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/")
self.assertTemplateUsed(resp, "useroptions.html")
self.assertIn("no-store", resp["Cache-Control"])
def test_logout(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/logout/")
self.assertEqual(resp.status_code, 200)
self.assertNotIn("_auth_user_id", self.client.session)
class RegisterTests(LdapTestCase):
def form(self, **overrides):
data = {"username": unique_uid("reg"), "password1": "reg-pass-12345",
"password2": "reg-pass-12345", "email": "reg@example.com",
"firstname": "Rita", "lastname": "Register"}
data.update(overrides)
return data
def assert_error(self, data, text):
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "error.html")
self.assertContains(resp, text)
self.assertEqual(len(mail.outbox), 0)
def test_form_renders(self):
self.assertTemplateUsed(self.client.get(REGISTER), "registeruser.html")
def test_validation_errors(self):
self.assert_error(self.form(username=""), "Please supply a username")
self.assert_error(self.form(username="Bad Name"), "lowercase letters")
self.assert_error(self.form(username="pipe|name"), "lowercase letters")
self.assert_error(self.form(password2="different"), "match")
self.assert_error(self.form(email="not-an-email"), "email address is invalid")
self.assert_error(self.form(lastname=""), "firstname and lastname")
def test_full_registration_and_activation(self):
data = self.form()
uid = data["username"]
self.track(uid)
resp = self.client.post(REGISTER, data)
self.assertTemplateUsed(resp, "confirm_email.html")
self.assertEqual(len(mail.outbox), 1)
self.assertIsNone(self.ldap_entry(uid), "user must not exist before activation")
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "usercreated.html")
entry = self.ldap_entry(uid)
self.assertIsNotNone(entry)
self.assertEqual(entry.mail.value, "reg@example.com")
self.assertEqual(entry.givenName.value, "Rita")
self.assertEqual(int(entry.gidNumber.value), settings.LDAP_CUSTOMER_GROUP_ID)
self.assertEqual(set(entry.objectClass.values) - {"top"},
set(settings.LDAP_USER_OBJECT_CLASSES))
self.assertTrue(entry.userPassword.value.startswith(b"{SSHA}"))
self.assertTrue(self.can_bind(uid, "reg-pass-12345"))
self.otp_post.assert_called_once() # OTP seed requested for new user
# Activation link is single use
self.assertEqual(self.client.get(link).status_code, 404)
def test_otp_server_down_does_not_fail_activation(self):
import requests
self.otp_post.side_effect = requests.ConnectionError("otp down")
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
with self.assertLogs("dal.views", "ERROR"):
resp = self.client.get(self.last_mail_link())
self.assertTemplateUsed(resp, "usercreated.html")
self.assertTrue(self.can_bind(data["username"], "reg-pass-12345"))
@override_settings(LDAP_USER_OBJECT_CLASSES=["inetOrgPerson", "posixAccount"])
def test_object_classes_are_configurable(self):
"""For servers without the openssh-lpk schema."""
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
classes = set(self.ldap_entry(data["username"]).objectClass.values)
self.assertNotIn("ldapPublicKey", classes)
self.assertTrue(self.can_bind(data["username"], "reg-pass-12345"))
def test_tampered_activation_token_is_rejected(self):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.assertEqual(self.client.get("/activate/not-the-token/").status_code, 404)
self.assertIsNone(self.ldap_entry(data["username"]))
def test_uid_numbers_are_unique(self):
uids = []
for _ in range(2):
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.client.get(self.last_mail_link())
uids.append(int(self.ldap_entry(data["username"]).uidNumber.value))
self.assertNotEqual(uids[0], uids[1])
def test_uid_number_is_above_every_existing_one(self):
from .base import admin_conn
self.make_ldap_user(ou="users") # random high uidNumber
conn = admin_conn()
conn.search(settings.ENTIRE_SEARCH_BASE, "(uidNumber=*)", attributes=["uidNumber"])
highest = max(int(e.uidNumber.value) for e in conn.entries)
conn.unbind()
data = self.form()
self.track(data["username"])
self.client.post(REGISTER, data)
self.client.get(self.last_mail_link())
self.assertEqual(int(self.ldap_entry(data["username"]).uidNumber.value), highest + 1)
def test_password_not_in_activation_link_or_db(self):
"""The plaintext password must never be stored or put in a URL."""
from dal.models import PendingRegistration
import base64
data = self.form(password1="Very-Secret-pw-1", password2="Very-Secret-pw-1")
self.track(data["username"])
self.client.post(REGISTER, data)
link = self.last_mail_link()
self.assertNotIn(base64.b64encode(b"Very-Secret-pw-1").decode(), link)
self.assertNotIn("Very-Secret-pw-1", mail.outbox[-1].body)
stored = PendingRegistration.objects.get(username=data["username"])
self.assertTrue(stored.password_hash.startswith("{SSHA}"))
self.assertNotIn("Very-Secret-pw-1", stored.password_hash)
def test_cannot_register_name_taken_by_staff(self):
"""Username in ou=users must not be re-registrable under ou=customer."""
staff = self.make_ldap_user(ou="users")
self.track(staff) # in case the bug creates a customer duplicate
self.client.post(REGISTER, self.form(username=staff))
if mail.outbox:
self.client.get(self.last_mail_link())
self.assertIsNone(
self.ldap_entry_in(staff, settings.LDAP_CUSTOMER_DN))
def test_taken_username_rejected_at_form(self):
existing = self.make_ldap_user()
self.assert_error(self.form(username=existing), "already taken")
def test_name_taken_between_signup_and_activation(self):
data = self.form()
uid = data["username"]
self.client.post(REGISTER, data)
link = self.last_mail_link()
self.make_ldap_user(uid=uid, ou="users") # someone else got it first
resp = self.client.get(link)
self.assertTemplateUsed(resp, "error.html")
self.assertIsNone(self.ldap_entry_in(uid, settings.LDAP_CUSTOMER_DN))
def ldap_entry_in(self, uid, base):
from .base import admin_conn
conn = admin_conn()
conn.search(base, "(uid={})".format(uid), attributes=["uid"])
found = list(conn.entries)
conn.unbind()
return found[0] if found else None
class ChangeDataTests(LdapTestCase):
def test_requires_login(self):
resp = self.client.get("/changedata/")
self.assertEqual(resp.status_code, 302)
def test_shows_and_updates_data(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.get("/changedata/")
self.assertTemplateUsed(resp, "changeuserdata.html")
self.assertContains(resp, "Tina")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "new@example.com"})
self.assertTemplateUsed(resp, "changeddata.html")
entry = self.ldap_entry(uid)
self.assertEqual(entry.givenName.value, "Neo")
self.assertEqual(entry.sn.value, "Name")
self.assertEqual(entry.mail.value, "new@example.com")
def test_rejects_invalid_email(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/changedata/", {
"firstname": "Neo", "lastname": "Name", "email": "broken"})
self.assertTemplateUsed(resp, "error.html")
self.assertEqual(self.ldap_entry(uid).mail.value, uid + "@example.com")
class ChangePasswordTests(LdapTestCase):
def setUp(self):
super().setUp()
self.uid = self.make_ldap_user(password="old-pass-1234")
self.login(self.uid, "old-pass-1234")
def post(self, old, new1, new2=None):
return self.client.post("/changepassword/", {
"oldpassword": old, "password1": new1, "password2": new2 or new1})
def test_wrong_old_password(self):
resp = self.post("wrong", "new-pass-1234")
self.assertContains(resp, "Wrong password")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_mismatch_and_too_short(self):
self.assertContains(self.post("old-pass-1234", "new-pass-1234", "other-pass-1234"),
"same password")
self.assertContains(self.post("old-pass-1234", "short"), "too short")
self.assertTrue(self.can_bind(self.uid, "old-pass-1234"))
def test_success_changes_ldap_password_and_logs_out(self):
resp = self.post("old-pass-1234", "new-pass-1234")
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(self.uid, "new-pass-1234"))
self.assertFalse(self.can_bind(self.uid, "old-pass-1234"))
self.assertNotIn("_auth_user_id", self.client.session)
class ResetPasswordTests(LdapTestCase):
def test_unknown_user(self):
resp = self.client.post(RESET, {"user": "does-not-exist"})
self.assertContains(resp, "does not exist")
self.assertEqual(len(mail.outbox), 0)
def test_full_reset_flow(self):
uid = self.make_ldap_user(password="old-pass-1234")
resp = self.client.post(RESET, {"user": uid})
self.assertTemplateUsed(resp, "send_resetrequest.html")
self.assertEqual(mail.outbox[0].to, ["{0} <{0}@example.com>".format(uid)])
link = self.last_mail_link()
resp = self.client.get(link)
self.assertTemplateUsed(resp, "resetpasswordnew.html")
self.assertEqual(self.client.get(link).status_code, 404, "link must be single use")
resp = self.client.post("/reset/", {
"user": uid, "password1": "brand-new-1234", "password2": "brand-new-1234"})
self.assertTemplateUsed(resp, "changedpassword.html")
self.assertTrue(self.can_bind(uid, "brand-new-1234"))
def test_verified_link_cannot_reset_another_user(self):
mine = self.make_ldap_user(password="old-pass-1234")
victim = self.make_ldap_user(password="victim-pass-1234")
self.client.post(RESET, {"user": mine})
self.client.get(self.last_mail_link())
self.client.post("/reset/", {
"user": victim, "password1": "attacker-1234", "password2": "attacker-1234"})
self.assertTrue(self.can_bind(victim, "victim-pass-1234"))
self.assertTrue(self.can_bind(mine, "attacker-1234"))
def test_verified_link_is_single_use(self):
uid = self.make_ldap_user(password="old-pass-1234")
self.client.post(RESET, {"user": uid})
self.client.get(self.last_mail_link())
self.client.post("/reset/", {
"user": uid, "password1": "first-new-1234", "password2": "first-new-1234"})
resp = self.client.post("/reset/", {
"user": uid, "password1": "second-new-1234", "password2": "second-new-1234"})
self.assertContains(resp, "invalid or has expired")
self.assertTrue(self.can_bind(uid, "first-new-1234"))
def test_garbage_reset_link_is_404(self):
self.assertEqual(self.client.get("/reset/%%%/abc/").status_code, 404)
def test_rejects_short_new_password(self):
uid = self.make_ldap_user(password="old-pass-1234")
self.client.post(RESET, {"user": uid})
self.client.get(self.last_mail_link())
resp = self.client.post("/reset/", {
"user": uid, "password1": "short", "password2": "short"})
self.assertContains(resp, "too short")
def test_reset_post_requires_a_valid_token(self):
"""POST /reset/ must not set a password without a verified link."""
victim = self.make_ldap_user(password="victim-pass-1234", ou="users")
self.client.post("/reset/", {
"user": victim, "password1": "attacker-1234", "password2": "attacker-1234"})
self.assertFalse(self.can_bind(victim, "attacker-1234", ou="users"),
"account takeover without any token")
def test_ldap_filter_injection_in_reset(self):
"""user='*' must not match the first entry in the whole tree."""
self.make_ldap_user(password="victim-pass-1234")
self.client.post("/reset/", {
"user": "*", "password1": "attacker-1234", "password2": "attacker-1234"})
from .base import admin_conn
conn = admin_conn()
conn.search(settings.ENTIRE_SEARCH_BASE, "(uid=*)", attributes=["uid"])
uids = [e.uid.value for e in conn.entries]
conn.unbind()
hijacked = [u for u in uids for ou in ("customer", "users")
if self.can_bind(u, "attacker-1234", ou=ou)]
self.assertEqual(hijacked, [])
class DeleteAccountTests(LdapTestCase):
def test_requires_login(self):
self.assertEqual(self.client.get("/deleteaccount/").status_code, 302)
def test_wrong_password_keeps_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "nope"})
self.assertContains(resp, "Wrong password")
self.assertIsNotNone(self.ldap_entry(uid))
def test_cannot_delete_someone_else(self):
me = self.make_ldap_user(password="cust-pass-123")
other = self.make_ldap_user(password="other-pass-123")
self.login(me, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": other, "password": "other-pass-123"})
self.assertContains(resp, "Unknown user")
self.assertIsNotNone(self.ldap_entry(other))
def test_delete_own_account(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/deleteaccount/", {"username": uid, "password": "cust-pass-123"})
self.assertTemplateUsed(resp, "deleteduser.html")
self.assertIsNone(self.ldap_entry(uid))
self.assertNotIn("_auth_user_id", self.client.session)
class UserCreateApiTests(LdapTestCase):
def test_validation(self):
self.assertEqual(self.client.post("/create/", {}).status_code, 400)
taken = self.make_ldap_user()
self.assertEqual(self.client.post("/create/", {
"username": taken, "email": "a@example.com", "firstname": "a", "lastname": "b"}
).status_code, 400)
self.assertEqual(self.client.post("/create/", {
"username": "ok-name", "email": "bad", "firstname": "a", "lastname": "b"}
).status_code, 400)
def test_creates_account_via_emailed_link(self):
uid = unique_uid("api")
self.track(uid)
resp = self.client.post("/create/", {
"username": uid, "email": "api@example.com",
"firstname": "Api", "lastname": "User"})
self.assertEqual(resp.status_code, 200)
body = mail.outbox[0].body
import re
password = re.search(r"Password: (\S+)", body).group(1)
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
self.assertTrue(self.can_bind(uid, password))
class OtpSeedTests(LdapTestCase):
def test_seed_list_shows_only_own_seeds(self):
import json
uid = self.make_ldap_user(password="cust-pass-123")
self.otp_get.return_value.text = json.dumps([
{"name": uid, "realm": "ungleich-auth", "seed": "MINE"},
{"name": "someone-else", "realm": "ungleich-auth", "seed": "THEIRS"},
])
self.login(uid, "cust-pass-123")
resp = self.client.get("/seeds/")
self.assertContains(resp, "MINE")
self.assertNotContains(resp, "THEIRS")
def test_api_does_not_print_password(self):
import io
from contextlib import redirect_stdout
uid = self.make_ldap_user(password="cust-pass-123")
out = io.StringIO()
with redirect_stdout(out), self.assertLogs("dal", "DEBUG") as logs:
import logging
logging.getLogger("dal").debug("sentinel")
self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertNotIn("cust-pass-123", out.getvalue())
self.assertNotIn("cust-pass-123", "\n".join(logs.output))
def test_otp_requests_have_a_timeout(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
self.client.get("/seeds/")
self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
calls = self.otp_get.call_args_list + self.otp_post.call_args_list
self.assertTrue(calls)
for call in calls:
self.assertEqual(call.kwargs.get("timeout"), 10)
def test_api_rejects_bad_credentials(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "x", "realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 400)
def test_api_creates_seed_in_allowed_realm_only(self):
uid = self.make_ldap_user(password="cust-pass-123")
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "evil-realm"})
self.assertEqual(resp.status_code, 403)
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 201)
DEAD_LDAP = "ldap://127.0.0.1:1"
class MultipleLdapServerTests(LdapTestCase):
@override_settings(LDAP_SERVERS=[DEAD_LDAP, TEST_LDAP_URI])
def test_fails_over_to_second_server(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
resp = self.client.post("/changedata/", {
"firstname": "Fail", "lastname": "Over", "email": "fo@example.com"})
self.assertTemplateUsed(resp, "changeddata.html")
self.assertEqual(self.ldap_entry(uid).sn.value, "Over")
@override_settings(LDAP_SERVERS=[DEAD_LDAP, TEST_LDAP_URI])
def test_registration_with_first_server_down(self):
uid = unique_uid("fo")
self.track(uid)
self.client.post("/register/", {
"username": uid, "password1": "reg-pass-12345", "password2": "reg-pass-12345",
"email": "fo@example.com", "firstname": "Fay", "lastname": "Lover"})
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
self.assertTrue(self.can_bind(uid, "reg-pass-12345"))
@override_settings(LDAP_SERVERS=[DEAD_LDAP])
def test_all_servers_down_login_fails_cleanly(self):
self.assertFalse(self.client.login(username="anyone", password="x"))
class HealthCheckTests(LdapTestCase):
def test_liveness_ignores_allowed_hosts(self):
resp = self.client.get("/healthz", HTTP_HOST="10.42.0.17:8000")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), {"status": "ok"})
def test_readiness_ok(self):
resp = self.client.get("/readyz", HTTP_HOST="10.42.0.17:8000")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["checks"], {"database": "ok", "ldap": "ok"})
@override_settings(LDAP_SERVERS=[DEAD_LDAP])
def test_readiness_fails_without_ldap(self):
resp = self.client.get("/readyz")
self.assertEqual(resp.status_code, 503)
self.assertEqual(resp.json()["checks"]["ldap"], "failed")
def test_other_paths_still_check_host(self):
resp = self.client.get("/", HTTP_HOST="10.42.0.17:8000")
self.assertEqual(resp.status_code, 400)
DEV_CA = str(settings.BASE_DIR) + "/dev/ldap/export/ca.crt"
@skipUnless("localhost:3389" in TEST_LDAP_URI, "TLS tests need the local dev LDAP and its CA")
class LdapTlsTests(LdapTestCase):
"""TLS against the dev LDAP (ldaps on :3636, StartTLS on :3389)."""
def setUp(self):
super().setUp()
import os
if not os.path.exists(DEV_CA):
self.fail("dev LDAP CA missing at {}; restart the ldap container".format(DEV_CA))
self.uid = self.make_ldap_user(password="tls-pass-1234")
def login_works(self):
from django.contrib.auth import authenticate
return authenticate(username=self.uid, password="tls-pass-1234") is not None
def assert_encrypted(self):
import ssl
from dal.ungleich_ldap import LdapManager
conn = LdapManager().get_admin_conn()
try:
self.assertIsInstance(conn.socket, ssl.SSLSocket)
finally:
conn.unbind()
@override_settings(LDAP_SERVERS=["ldaps://localhost:3636"], LDAP_CA_FILE=DEV_CA)
def test_ldaps_with_ca(self):
self.assertTrue(self.login_works())
self.assert_encrypted()
@override_settings(LDAP_SERVERS=["ldap://localhost:3389"], LDAP_USE_TLS=True,
LDAP_CA_FILE=DEV_CA)
def test_starttls_with_ca(self):
self.assertTrue(self.login_works())
self.assert_encrypted()
@override_settings(LDAP_SERVERS=["ldaps://[::1]:3636"], LDAP_CA_FILE=DEV_CA)
def test_ldaps_over_ipv6_literal(self):
self.assertTrue(self.login_works())
self.assert_encrypted()
@override_settings(LDAP_SERVERS=["ldap://[::1]:3389"])
def test_plain_ldap_over_ipv6_literal(self):
self.assertTrue(self.login_works())
@override_settings(LDAP_SERVERS=["ldaps://localhost:3636"], LDAP_CA_FILE="")
def test_untrusted_certificate_is_rejected(self):
self.assertFalse(self.login_works())
self.assertEqual(self.client.get("/readyz").status_code, 503)
@override_settings(LDAP_SERVERS=["ldap://localhost:3389"], LDAP_USE_TLS=True,
LDAP_CA_FILE="")
def test_untrusted_certificate_is_rejected_on_starttls(self):
self.assertFalse(self.login_works())
@override_settings(LDAP_SERVERS=["ldaps://127.0.0.1:3636"], LDAP_CA_FILE=DEV_CA)
def test_hostname_mismatch_is_rejected(self):
"""The certificate does not cover 127.0.0.1."""
self.assertFalse(self.login_works())
@override_settings(LDAP_SERVERS=["ldaps://127.0.0.1:3636"], LDAP_CA_FILE="",
LDAP_TLS_VERIFY=False)
def test_verification_can_be_disabled(self):
self.assertTrue(self.login_works())
@override_settings(OTP_SERVER="")
class OtpDisabledTests(LdapTestCase):
"""OTPSERVER unset: everything but the seed features keeps working."""
def test_registration_works_without_otp(self):
uid = unique_uid("nootp")
self.track(uid)
self.client.post(REGISTER, {
"username": uid, "password1": "reg-pass-12345", "password2": "reg-pass-12345",
"email": "n@example.com", "firstname": "No", "lastname": "Otp"})
self.assertTemplateUsed(self.client.get(self.last_mail_link()), "usercreated.html")
self.assertTrue(self.can_bind(uid, "reg-pass-12345"))
self.otp_post.assert_not_called()
def test_seed_page_and_api_report_not_configured(self):
uid = self.make_ldap_user(password="cust-pass-123")
self.login(uid, "cust-pass-123")
self.assertContains(self.client.get("/seeds/"), "OTP is not configured")
resp = self.client.post("/otp/", {"username": uid, "password": "cust-pass-123",
"realm": "ungleich-auth"})
self.assertEqual(resp.status_code, 503)
self.otp_get.assert_not_called()

View file

@ -1,82 +1,14 @@
import base64
import hashlib
import random
import ssl
import ldap3
import ldap3.core.exceptions
import ldap3.utils.config
from django.conf import settings
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
import logging
logger = logging.getLogger(__name__)
# Seconds to wait for a TCP connection, and to skip a server that failed.
LDAP_CONNECT_TIMEOUT = 5
LDAP_OFFLINE_RETRY = 60
# How many times to cycle through all servers before giving up
# (ldap3's active=True would retry forever when all are down).
LDAP_POOL_PASSES = 2
# Pause between passes (ldap3 defaults to 10s, which stalls requests).
ldap3.utils.config.set_config_parameter('POOLING_LOOP_TIMEOUT', 1)
class VerifiedTls(ldap3.Tls):
"""
TLS for ldaps:// and StartTLS, verified by Python's ssl module.
ldap3 does not verify server certificates by default, and its own
hostname check cannot match IP addresses. This uses the standard
library instead: certificate chain and hostname (or IP, e.g. an IPv6
literal) are checked, SNI is sent, and TLS 1.2 is the minimum.
"""
def __init__(self, verify=True, ca_file=None):
super().__init__(
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
ca_certs_file=ca_file or None,
)
self.verify = verify
def wrap_socket(self, connection, do_handshake=False):
context = ssl.create_default_context(cafile=self.ca_certs_file)
context.minimum_version = ssl.TLSVersion.TLSv1_2
if not self.verify:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
connection.socket = context.wrap_socket(
connection.socket,
server_side=False,
server_hostname=connection.server.host,
do_handshake_on_connect=do_handshake,
)
_pools = {}
def get_server_pool(uris, verify=True, ca_file=None):
"""
One ldap3 ServerPool per process (per list of URIs and TLS settings):
connections go to the first reachable server, and a server that is down
is skipped for LDAP_OFFLINE_RETRY seconds instead of costing a timeout
every request.
"""
key = (tuple(uris), verify, ca_file)
if key not in _pools:
if not verify:
logger.warning("LDAP TLS certificate verification is disabled")
tls = VerifiedTls(verify=verify, ca_file=ca_file)
_pools[key] = ldap3.ServerPool(
[ldap3.Server(uri, connect_timeout=LDAP_CONNECT_TIMEOUT, tls=tls)
for uri in uris],
ldap3.FIRST, active=LDAP_POOL_PASSES, exhaust=LDAP_OFFLINE_RETRY,
)
return _pools[key]
class LdapManager:
__instance = None
def __new__(cls):
@ -89,11 +21,7 @@ class LdapManager:
Initialize the LDAP subsystem.
"""
self.rng = random.SystemRandom()
self.server = get_server_pool(
settings.LDAP_SERVERS,
verify=settings.LDAP_TLS_VERIFY,
ca_file=settings.LDAP_CA_FILE,
)
self.server = ldap3.Server(settings.AUTH_LDAP_SERVER)
def get_admin_conn(self):
@ -116,14 +44,7 @@ class LdapManager:
The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and
can be used to set *user*, *password* and other useful arguments.
"""
conn = ldap3.Connection(self.server, **kwargs)
if settings.LDAP_USE_TLS:
conn.open()
# Never fall through to a plaintext bind if StartTLS fails.
if not conn.start_tls():
conn.unbind()
raise ldap3.core.exceptions.LDAPStartTLSError("StartTLS failed")
return conn
return ldap3.Connection(self.server, **kwargs)
def _ssha_password(self, password):
@ -149,31 +70,32 @@ class LdapManager:
return passwd
def hash_password(self, password):
"""Return the {SSHA} hash of *password* (str) as an ascii str."""
return self._ssha_password(password.encode("utf-8")).decode("ascii")
def username_taken(self, uid):
"""True if *uid* exists anywhere in the tree (customers, users, ...)."""
exists, _ = self.check_user_exists(
uid=uid, attributes=["uid"],
search_base=settings.ENTIRE_SEARCH_BASE
)
return bool(exists)
def create_user(self, user, password_hash, firstname, lastname, email):
"""Create a customer entry. *password_hash* comes from hash_password()."""
if self.username_taken(user):
raise Exception("The username {} is already taken.".format(user))
uidNumber = self._next_uid_number()
logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber))
def create_user(self, user, password, firstname, lastname, email):
conn = self.get_admin_conn()
uidNumber = self._get_max_uid() + 1
logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber))
user_exists = True
while user_exists:
user_exists, _ = self.check_user_exists(
"",
'(&(objectClass=inetOrgPerson)(objectClass=posixAccount)'
'(objectClass=top)(uidNumber={uidNumber}))'.format(
uidNumber=uidNumber
)
)
if user_exists:
logger.debug(
"{uid} exists. Trying next.".format(uid=uidNumber)
)
uidNumber += 1
logger.debug("{uid} does not exist. Using it".format(uid=uidNumber))
self._set_max_uid(uidNumber)
try:
uid = user # user.encode("utf-8")
conn.add("uid={uid},{customer_dn}".format(
uid=escape_rdn(uid), customer_dn=settings.LDAP_CUSTOMER_DN
uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN
),
settings.LDAP_USER_OBJECT_CLASSES,
["inetOrgPerson", "posixAccount", "ldapPublickey"],
{
"uid": [uid],
"sn": [lastname.encode("utf-8")],
@ -185,7 +107,9 @@ class LdapManager:
"loginShell": ["/bin/bash"],
"homeDirectory": ["/home/{}".format(user).encode("utf-8")],
"mail": email.encode("utf-8"),
"userPassword": [password_hash.encode("ascii")]
"userPassword": [self._ssha_password(
password.encode("utf-8")
)]
}
)
logger.debug('Created user %s %s' % (user.encode('utf-8'),
@ -292,7 +216,7 @@ class LdapManager:
result = conn.search(
search_base=search_base,
search_filter=search_filter if len(search_filter)> 0 else
'(uid={uid})'.format(uid=escape_filter_chars(uid)),
'(uid={uid})'.format(uid=uid),
attributes=attributes
)
entries = conn.entries
@ -310,7 +234,7 @@ class LdapManager:
conn = self.get_admin_conn()
try:
return_val = conn.delete(
("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=escape_rdn(uid)),
("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid),
)
msg = "success"
except Exception as ex:
@ -321,34 +245,40 @@ class LdapManager:
conn.unbind()
return return_val, msg
def _next_uid_number(self):
def _set_max_uid(self, max_uid):
"""
Return the next free uidNumber: one above the highest uidNumber in
the whole tree, and above LDAP_DEFAULT_START_UID.
a utility function to save max_uid value to a file
Read from LDAP on every call (paged, so server size limits don't cut
it short) instead of a local file, so it works with several replicas
and survives restarts. Two registrations activated at the very same
moment could still pick the same number.
:param max_uid: an integer representing the max uid
:return:
"""
with open(settings.LDAP_MAX_UID_FILE_PATH, 'w+') as handler:
handler.write(str(max_uid))
def _get_max_uid(self):
"""
A utility function to read the max uid value that was previously set
:return: An integer representing the max uid value that was previously
set
"""
highest = settings.LDAP_DEFAULT_START_UID
conn = self.get_admin_conn()
try:
results = conn.extend.standard.paged_search(
search_base=settings.ENTIRE_SEARCH_BASE,
search_filter='(&(objectClass=posixAccount)(uidNumber=*))',
attributes=['uidNumber'],
paged_size=500,
generator=True,
)
for result in results:
if result.get('type') != 'searchResEntry':
continue
value = result['attributes'].get('uidNumber')
if isinstance(value, list):
value = value[0] if value else None
if value is not None:
highest = max(highest, int(value))
finally:
conn.unbind()
return highest + 1
with open(settings.LDAP_MAX_UID_FILE_PATH, 'r+') as handler:
try:
return_value = int(handler.read())
except ValueError as ve:
logger.error(
"Error reading int value from {}. {}"
"Returning default value {} instead".format(
settings.LDAP_MAX_UID_PATH,
str(ve),
settings.LDAP_DEFAULT_START_UID
)
)
return_value = settings.LDAP_DEFAULT_START_UID
return return_value
except FileNotFoundError as fnfe:
logger.error("File not found : " + str(fnfe))
return_value = settings.LDAP_DEFAULT_START_UID
logger.error("So, returning UID={}".format(return_value))
return return_value

View file

@ -1,5 +1,6 @@
# The different URLs this service does use
from django.urls import path
from django.conf.urls import url
from django.contrib import admin
# Import the classes for the views
@ -29,7 +30,7 @@ urlpatterns = [
path('index/', Index.as_view(), name="index"),
path('logout/', LogOut.as_view(), name="logout"),
path('reset/<str:user>/<str:token>/', ResetRequest.as_view()),
path('activate/<str:token>/', ActivateAccount.as_view(), name="activate"),
path('activate/<str:user>/<str:pwd>/<str:firstname>/<str:lastname>/<str:email>/<str:token>/', ActivateAccount.as_view()),
path('reset/', ResetRequest.as_view(), name="reset"),
path('otp/', SeedRetrieveCreate.as_view(), name="seed"),
path('', Index.as_view(), name="login_index"),

View file

@ -2,30 +2,33 @@
from django.shortcuts import render
from django.views.generic import View, FormView
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.core.validators import validate_email, ValidationError
from django.urls import reverse_lazy
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core.mail import EmailMessage
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import PendingRegistration, ResetToken
from .models import ResetToken
from .forms import LoginForm
from .ungleich_ldap import LdapManager
from decouple import config, Csv
from pyotp import TOTP
import logging
import re
import secrets
logger = logging.getLogger(__name__)
# Imports for the extra stuff not in django
from base64 import b64encode, b64decode
import time
from datetime import datetime
from random import choice, randint
import string
import requests
import json
@ -34,49 +37,54 @@ from django.contrib.auth.mixins import LoginRequiredMixin
# Small helper used for registration.
def is_username_valid(username):
return re.fullmatch(r"[a-z0-9_-]+", username)
return re.fullmatch(r"^[a-z|0-9|\-|_]+$", username)
# Session key holding the user a password reset link was verified for,
# and how long (seconds) that verification stays valid.
RESET_SESSION_KEY = 'password_reset'
RESET_SESSION_MAX_AGE = 60 * 60
admin_seed = config('ADMIN_SEED')
admin_name = config('ADMIN_NAME')
admin_realm = config('ADMIN_REALM')
user_realm = config('USER_REALM')
otp_url = config('OTPSERVER')
def activate_account_link(base_url, user, pwd, firstname, lastname, email, epochutc):
"""Store a pending registration and return its activation link.
The password is hashed right away; the link only carries a random token.
"""
token = secrets.token_urlsafe(32)
PendingRegistration.objects.create(
token=token, username=user, email=email, firstname=firstname,
lastname=lastname, password_hash=LdapManager().hash_password(pwd),
creation=epochutc,
tokengen = PasswordResetTokenGenerator()
pseudouser = PseudoUser()
token = tokengen.make_token(pseudouser)
buser = bytes(user, 'utf-8')
bpwd = bytes(pwd, 'utf-8')
bfirstname = bytes(firstname, 'utf-8')
blasttname = bytes(lastname, 'utf-8')
bemail = bytes(email, 'utf-8')
userpart = b64encode(buser)
pwdpart = b64encode(bpwd)
fnpart = b64encode(bfirstname)
lnpart = b64encode(blasttname)
mailpart = b64encode(bemail)
# create entry into the database
newdbentry = ResetToken(user=user, token=token, creation=epochutc)
newdbentry.save()
# set up the link
link = "{base_url}/activate/{user}/{pwd}/{fn}/{ln}/{mail}/{token}/".format(
base_url=base_url, user=userpart.decode('utf-8'),
pwd=pwdpart.decode('utf-8'),
fn=fnpart.decode('utf-8'),
ln=lnpart.decode('utf-8'),
mail=mailpart.decode('utf-8'),
token=token
)
return "{base_url}/activate/{token}/".format(base_url=base_url, token=token)
def now_epoch():
"""Current Unix time in seconds (always UTC)."""
return int(time.time())
# Same alphabet Django's make_random_password() used (no look-alike chars).
PASSWORD_CHARS = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'
def random_password(length=16):
return ''.join(secrets.choice(PASSWORD_CHARS) for _ in range(length))
return link
def clean_db():
"""Revoves outdated tokens"""
# cutoff time is set to 24h hours
cutoff = now_epoch() - (24*60*60)
# using utcnow() to have no headache with timezones
cutoff = int(datetime.utcnow().timestamp()) - (24*60*60)
# Get all tokens older than 24 hours
ResetToken.objects.filter(creation__lt=cutoff).delete()
PendingRegistration.objects.filter(creation__lt=cutoff).delete()
oldtokens = ResetToken.objects.all().filter(creation__lt=cutoff)
for token in oldtokens:
# delete all tokens older than 24 hours
token.delete()
return True
class Index(FormView):
@ -93,7 +101,7 @@ class Index(FormView):
return render(self.request, 'useroptions.html', { 'user': user } )
return render(self.request, 'loginfailed.html')
@method_decorator(cache_control(no_cache=True, must_revalidate=True, no_store=True))
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
def get(self, request, *args, **kwargs):
if self.request.user.is_authenticated:
return render(self.request, 'useroptions.html',
@ -105,7 +113,7 @@ class Register(View):
def get(self, request):
return render(request, 'registeruser.html')
# Someone filled out the register page and do some basic checks
# Someone filled out the register page, do some basic checks and throw it at nameko
def post(self, request):
service = 'register an user'
urlname = 'register'
@ -123,12 +131,6 @@ class Register(View):
'service': service,
'error': 'You can only use lowercase letters, numbers, underscores and the dash character in your username.' } )
if LdapManager().username_taken(username):
return render(request, 'error.html', {
'urlname': urlname,
'service': service,
'error': 'This username is already taken.' } )
password1 = request.POST.get('password1')
password2 = request.POST.get('password2')
if password1 != password2:
@ -155,7 +157,7 @@ class Register(View):
pwd = r'%s' % password1
try:
creationtime = now_epoch()
creationtime = int(datetime.utcnow().timestamp())
base_url = "{0}://{1}".format(self.request.scheme,
self.request.get_host())
link = activate_account_link(base_url, username, pwd, firstname, lastname, email, creationtime)
@ -302,7 +304,7 @@ class ResetPassword(View):
# Sends an email to the user with the 24h active link for a password reset
def email(self, user, email, base_url):
# getting epoch for the time now in UTC to spare us headache with timezones
creationtime = now_epoch()
creationtime = int(datetime.utcnow().timestamp())
# Construct the data for the email
email_from = settings.EMAIL_FROM_ADDRESS
to = ['%s <%s>' % (user, email)]
@ -330,7 +332,10 @@ class ResetPassword(View):
# Builds the reset link for the email and puts the token into the database
def build_reset_link(self, user, epochutc, base_url):
# set up the data
token = secrets.token_urlsafe(32)
tokengen = PasswordResetTokenGenerator()
# create some noise for use in the tokengenerator
pseudouser = PseudoUser()
token = tokengen.make_token(pseudouser)
buser = bytes(user, 'utf-8')
userpart = b64encode(buser)
# create entry into the database
@ -340,6 +345,7 @@ class ResetPassword(View):
link = "{base_url}/reset/{user}/{token}/".format(
base_url=base_url,user=userpart.decode('utf-8'),token=token
)
logger.debug("User reset url is {}".format(link))
return link
@ -358,10 +364,9 @@ class ResetRequest(View):
if user == None or token == None:
return HttpResponse('Invalid URL.', status=404)
# extract user from b64 format
try:
user_clean = b64decode(user.encode('utf-8')).decode('utf-8')
except Exception:
return HttpResponse('Invalid URL.', status=404)
tmp_user = bytes(user, 'utf-8')
user = b64decode(tmp_user)
user_clean = user.decode('utf-8')
# set checks_out = True if token is found in database
checks_out = False
dbentries = ResetToken.objects.all().filter(user=user_clean)
@ -373,13 +378,9 @@ class ResetRequest(View):
# No token was found
if not checks_out:
return HttpResponse('Invalid URL.', status=404)
# Token was found: remember in the session which user this browser
# proved control of, and supply the form. The POST only trusts this.
request.session[RESET_SESSION_KEY] = {
'user': user_clean,
'verified_at': now_epoch(),
}
return render(request, 'resetpasswordnew.html', { 'user': user_clean } )
# Token was found, supply the form
else:
return render(request, 'resetpasswordnew.html', { 'user': user_clean } )
# Gets the post form with the new password and sets it
@ -388,14 +389,11 @@ class ResetRequest(View):
# get the supplied passwords
password1 = request.POST.get("password1")
password2 = request.POST.get("password2")
# The user comes from the session set by a verified reset link,
# never from the form.
verified = request.session.get(RESET_SESSION_KEY) or {}
user = verified.get('user')
age = now_epoch() - verified.get('verified_at', 0)
if not user or age > RESET_SESSION_MAX_AGE:
request.session.pop(RESET_SESSION_KEY, None)
return render(request, 'error.html', { 'service': service, 'error': 'Your reset link is invalid or has expired. Please request a new one.' } )
# get the hidden value of user
user = request.POST.get("user")
# some checks over the supplied data
if user == "" or not user:
return render(request, 'error.html', { 'service': service, 'error': 'Something went wrong. Did you use the supplied form?' } )
if password1 == "" or not password1 or password2 == "" or not password2:
return render(request, 'error.html', { 'service': service, 'error': 'Please supply a password and confirm it.' } )
if password1 != password2:
@ -411,7 +409,6 @@ class ResetRequest(View):
)
# password change successful
if result:
request.session.pop(RESET_SESSION_KEY, None)
return render(request, 'changedpassword.html', { 'user': user } )
# Something went wrong while changing the password
else:
@ -524,44 +521,74 @@ class LogOut(View):
# TO be clarified
# To trick the tokengenerator to work with us, because we don't really
# have the expected user Class since we are reading the user from a form
# We store the tokens and don't have to use the check function,
# some one time data works fine.
class LastLogin():
def replace(self, microsecond=0, tzinfo=None):
return randint(1,100000)
class PseudoUser():
# easiest way to handle the check for lastlogin
last_login = LastLogin()
# random alphanumeric strings for primary key and password, just used for token generation
pk = ''.join(choice(string.ascii_letters + string.digits) for _ in range(20))
password = ''.join(choice(string.ascii_letters + string.digits) for _ in range(30))
class ActivateAccount(View):
def get(self, request, token=None):
def get(self, request, user=None, pwd=None, firstname=None, lastname=None, email=None, token=None):
clean_db()
pending = PendingRegistration.objects.filter(token=token).first()
if pending is None:
if token is None:
return HttpResponse('Invalid URL', status=404)
elem_list = [user, pwd, firstname, lastname, email]
clean_list = []
for value in elem_list:
try:
value_temp = bytes(value, 'utf-8')
value_decode = b64decode(value_temp)
value_clean = value_decode.decode('utf-8')
clean_list.append(value_clean)
except Exception as e:
return HttpResponse('Invalid URL', status=404)
checks_out = False
dbentries = ResetToken.objects.all().filter(user=clean_list[0])
for entry in dbentries:
if entry.token == token:
# found the token, now delete it since it's used
checks_out = True
entry.delete()
# No token was found
if not checks_out:
return HttpResponse('Invalid URL.', status=404)
# single use: consumed whether or not the creation below succeeds
pending.delete()
# Token was found, create user
try:
ldap_manager = LdapManager()
ldap_manager.create_user(
pending.username, pending.password_hash,
pending.firstname, pending.lastname, pending.email
clean_list[0], clean_list[1], clean_list[2], clean_list[3], clean_list[4]
)
req = requests.post(otp_url, data=json.dumps(
{
'auth_token': TOTP(admin_seed).now(),
'auth_name': admin_name,
'auth_realm': admin_realm,
'name': clean_list[0],
'realm': user_realm
}), headers={'Content-Type': 'application/json'})
if req.status_code != 201:
logger.error("User {} failed to create its otp seed".format(clean_list[0]))
#Send welcome email
except Exception as e:
return render(request, 'error.html', {'urlname': 'register',
'service': 'register an user',
'error': e})
# The account exists from here on. An OTP seed failure is logged but
# must not tell the user that registration failed.
if not settings.OTP_SERVER:
return render(request, 'usercreated.html', { 'user': pending.username } )
try:
req = requests.post(settings.OTP_SERVER, data=json.dumps(
{
'auth_token': TOTP(settings.OTP_ADMIN_SEED).now(),
'auth_name': settings.OTP_ADMIN_NAME,
'auth_realm': settings.OTP_ADMIN_REALM,
'name': pending.username,
'realm': settings.OTP_USER_REALM
}), headers={'Content-Type': 'application/json'},
timeout=settings.OTP_TIMEOUT)
if req.status_code != 201:
logger.error("User {} failed to create its otp seed".format(pending.username))
except requests.RequestException as e:
logger.error("User {} failed to create its otp seed: {}".format(pending.username, e))
return render(request, 'usercreated.html', { 'user': pending.username } )
return render(request, 'usercreated.html', { 'user': clean_list[0] } )
class UserCreateAPI(APIView):
@ -578,9 +605,6 @@ class UserCreateAPI(APIView):
if not is_username_valid(username):
return Response('Username is not valid.', 400)
if LdapManager().username_taken(username):
return Response('Username is already taken.', 400)
try:
validate_email(email)
except ValidationError:
@ -588,11 +612,11 @@ class UserCreateAPI(APIView):
if not firstname or not lastname:
return Response('Please provide firstname and lastname', 400)
pwd = random_password()
pwd = r'%s' % User.objects.make_random_password()
base_url = "{0}://{1}".format(self.request.scheme,
self.request.get_host())
creationtime = now_epoch()
creationtime = int(datetime.utcnow().timestamp())
link = activate_account_link(base_url, username, pwd, firstname, lastname, email, creationtime)
# Construct the data for the email
@ -622,17 +646,13 @@ class UserCreateAPI(APIView):
return Response('An email with activation link has been sent in order to complete your registration. Please check your inbox.', 200)
OTP_NOT_CONFIGURED = 'OTP is not configured on this server.'
class SeedRetrieveCreate(APIView):
def post(self, request):
if not settings.OTP_SERVER:
return Response(OTP_NOT_CONFIGURED, 503)
try:
username = request.data['username']
password = request.data[r'password']
realm = request.data['realm']
print(password)
except KeyError:
return Response('You need to specify username, password, and realm values', 400)
# authenticate the user against ldap
@ -640,12 +660,11 @@ class SeedRetrieveCreate(APIView):
user = authenticate(username=username, password=password)
if user is not None:
req = requests.get(settings.OTP_SERVER, data=json.dumps(
req = requests.get(otp_url, data=json.dumps(
{
'auth_token': TOTP(settings.OTP_ADMIN_SEED).now(),
'auth_name': settings.OTP_ADMIN_NAME,
'auth_realm': settings.OTP_ADMIN_REALM}), headers={'Content-Type': 'application/json'},
timeout=settings.OTP_TIMEOUT)
'auth_token': TOTP(admin_seed).now(),
'auth_name': admin_name,
'auth_realm': admin_realm}), headers={'Content-Type': 'application/json'})
response_data = json.loads(req.text)
@ -653,19 +672,18 @@ class SeedRetrieveCreate(APIView):
if elem['name'] == username and elem['realm'] == realm:
return Response(elem, 200)
# If doesn't find a match then check if the realm is allowed and create the user
allowed_realms = settings.OTP_ALLOWED_REALMS
allowed_realms = config('ALLOWED_REALMS', cast=Csv())
if realm not in allowed_realms:
return Response('Not allowed to perform this action.', 403)
else:
req = requests.post(settings.OTP_SERVER, data=json.dumps(
req = requests.post(otp_url, data=json.dumps(
{
'auth_token': TOTP(settings.OTP_ADMIN_SEED).now(),
'auth_name': settings.OTP_ADMIN_NAME,
'auth_realm': settings.OTP_ADMIN_REALM,
'auth_token': TOTP(admin_seed).now(),
'auth_name': admin_name,
'auth_realm': admin_realm,
'name': username,
'realm': realm
}), headers={'Content-Type': 'application/json'},
timeout=settings.OTP_TIMEOUT)
}), headers={'Content-Type': 'application/json'})
if req.status_code == 201:
msg = json.loads(req.text)
return Response(msg, 201)
@ -679,17 +697,12 @@ class SeedRetrieveCreate(APIView):
class Seeds(LoginRequiredMixin, View):
login_url = reverse_lazy('login_index')
def get(self, request):
if not settings.OTP_SERVER:
return render(request, 'error.html', {
'urlname': 'index', 'service': 'show your OTP seeds',
'error': OTP_NOT_CONFIGURED})
seedlist = []
response = requests.get(
settings.OTP_SERVER,
timeout=settings.OTP_TIMEOUT,
otp_url,
headers={'Content-Type': 'application/json'},
data=json.dumps(
{'auth_name': settings.OTP_ADMIN_NAME, 'auth_realm': settings.OTP_ADMIN_REALM, 'auth_token': TOTP(settings.OTP_ADMIN_SEED).now()}))
{'auth_name': admin_name, 'auth_realm': admin_realm, 'auth_token': TOTP(admin_seed).now()}))
response_data = json.loads(response.text)
for i in range(len(response_data)):
if response_data[i]['name'] == request.user.username:

View file

@ -1,118 +0,0 @@
# Kubernetes deployment
`base/` holds the manifests for the account webapp: Deployment, Service,
Ingress, PodDisruptionBudget and ConfigMap. Apply it with
`kubectl apply -k` (kustomize is built into kubectl).
## What the cluster needs to provide
- **PostgreSQL.** Sessions, password-reset tokens and pending registrations
live in the database, so more than one replica needs PostgreSQL. Point
`DATABASE_HOST` at it. The base doesn't ship a database; use the
cluster's managed or operator-run PostgreSQL.
- **An ingress controller with TLS.** The app expects TLS to terminate at
the ingress and trusts `X-Forwarded-Proto` (`TRUST_X_FORWARDED_PROTO=True`).
- **Network access** to the LDAP servers, the SMTP relay and the OTP server.
## Deploying
1. Build and push the image from the repository root:
```sh
docker build -t registry.example.com/ungleich-user:1.0.0 .
docker push registry.example.com/ungleich-user:1.0.0
```
2. Copy `base/` into your environment's overlay (or edit it in place) and set:
- `images:` in `kustomization.yaml`: the registry and a pinned tag
- `configmap.yaml`: every value marked `REQUIRED` (`.env.example` lists
all settings with their defaults)
- `ingress.yaml`: the ingress class and the TLS secret or cert-manager
annotations
3. Create the Secret. It isn't part of the base, so credentials never go
into git. See `secret.example.yaml`:
```sh
kubectl create secret generic ungleich-user \
--from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
--from-literal=LDAP_ADMIN_DN='cn=...' \
--from-literal=LDAP_ADMIN_PASSWORD='...' \
--from-literal=DATABASE_PASSWORD='...' \
--from-literal=ADMIN_SEED='...'
```
4. Apply: `kubectl apply -k deploy/k8s/base` (or your overlay).
## How it behaves
| Concern | Behaviour |
|---|---|
| Migrations | An init container runs `manage.py migrate_locked` in each pod. A PostgreSQL advisory lock makes pods that start together wait for the first one instead of migrating at the same time. |
| Probes | `/healthz` (liveness) has no dependencies, so an LDAP outage never restarts pods. `/readyz` (readiness) needs the database and at least one LDAP server. Both bypass `ALLOWED_HOSTS`. |
| LDAP failover | `LDAPSERVER` is a space-separated list. The first reachable server is used, and a failed server is skipped for 60 seconds. |
| Security | Runs as uid 10001 with a read-only root filesystem, all capabilities dropped, no privilege escalation, the RuntimeDefault seccomp profile and no service account token. The image has no sshd. |
| Logs | stdout/stderr only. `LOG_LEVEL` sets the level. |
| IPv6 | gunicorn listens on `[::]:8000`, which accepts IPv6 and IPv4. LDAP, PostgreSQL and SMTP work over IPv6, including IPv6-literal URIs such as `ldaps://[2a0a:…]:636`. Tested on an IPv6-only cluster. |
| LDAP TLS | `ldaps://` and StartTLS verify the server certificate and hostname. For a private CA, mount the CA certificate and set `LDAP_CA_FILE` (see below). |
| Rollouts | Rolling updates add one new pod before removing an old one (`maxSurge: 1`, `maxUnavailable: 0`). The PodDisruptionBudget keeps at least one pod running during node drains. |
### LDAP servers with a private CA
The system trust store is used by default. If the LDAP certificates come
from a private CA, put the CA certificate in a ConfigMap and point
`LDAP_CA_FILE` at it:
```sh
kubectl create configmap ldap-ca --from-file=ca.crt=./ldap-ca.crt
```
```yaml
# in the Deployment, for both the migrate init container and the web container
env:
- {name: LDAP_CA_FILE, value: /etc/ldap-ca/ca.crt}
volumeMounts:
- {name: ldap-ca, mountPath: /etc/ldap-ca, readOnly: true}
# and under volumes:
- {name: ldap-ca, configMap: {name: ldap-ca}}
```
The CA certificate must carry the usual CA extensions (`basicConstraints`
and `keyUsage`). Python 3.13+ rejects CAs without them.
### Before going live, check with the operators
- The ConfigMap uses `ldaps://` (port 636), which production serves.
Confirm that `ldap1` serves it too (it was checked on `ldap2`).
- HSTS (`SECURE_HSTS_SECONDS`) is off. Enabling it, especially for
subdomains, affects every `*.ungleich.ch` site.
- Emails sent before the switch contain activation links in the old
format. Those links stop working, and the affected users have to
register again.
## Local test cluster (dev overlay)
`dev/` deploys the app into a [kind](https://kind.sigs.k8s.io/) cluster
together with two independent test LDAP servers, PostgreSQL and Mailpit,
which catches outgoing mail. All data is throwaway and every credential
is a dev value.
```sh
kind create cluster --name ungleich-user
docker build -t ungleich-user:dev .
docker compose -f docker-compose.dev.yml build ldap
kind load docker-image ungleich-user:dev ungleich-user-dev-ldap:2.6 --name ungleich-user
kubectl apply -k deploy/k8s/dev
kubectl -n ungleich-user-dev rollout status deploy/ungleich-user
deploy/k8s/dev/e2e.sh # probes, signup via SMTP, activation, login, LDAP failover
```
To test an IPv6-only cluster, like ungleich's, create it with
`networking: {ipFamily: ipv6}` in a kind config file. Docker Desktop has no
IPv6 internet access, so `kind load` every image (including `postgres:18-alpine`
and `axllent/mailpit:v1.27`) before applying.
After rebuilding the image, run `kind load` again and then
`kubectl -n ungleich-user-dev rollout restart deploy/ungleich-user`.
To remove the cluster: `kind delete cluster --name ungleich-user`.

View file

@ -1,35 +0,0 @@
# Non-secret settings. See .env.example and docker.md for every variable.
apiVersion: v1
kind: ConfigMap
metadata:
name: ungleich-user
data:
ALLOWED_HOSTS: "account.ungleich.ch"
CSRF_TRUSTED_ORIGINS: "https://account.ungleich.ch"
TRUST_X_FORWARDED_PROTO: "True"
DEBUG: "False"
LOG_LEVEL: "INFO"
LDAPSERVER: "ldaps://ldap1.ungleich.ch ldaps://ldap2.ungleich.ch"
LDAP_USE_TLS: "False" # StartTLS; not used with ldaps://
LDAPSEARCH: "ou=customer,dc=ungleich,dc=ch ou=users,dc=ungleich,dc=ch"
ENTIRE_SEARCH_BASE: "dc=ungleich,dc=ch"
LDAP_CUSTOMER_DN: "ou=customer,dc=ungleich,dc=ch"
LDAP_CUSTOMER_GROUP_ID: "10004" # gidNumber for new customers
LDAP_DEFAULT_START_UID: "10000" # uidNumbers start above this
DATABASE_HOST: "" # REQUIRED for >1 replica: PostgreSQL host
DATABASE_NAME: "ungleich_user"
DATABASE_USER: "ungleich_user"
DATABASE_SSLMODE: "prefer"
EMAIL_FROM_ADDRESS: "" # REQUIRED
EMAIL_HOST: "localhost"
EMAIL_PORT: "25"
EMAIL_USE_TLS: "True"
OTPSERVER: "" # REQUIRED
ADMIN_NAME: ""
ADMIN_REALM: ""
USER_REALM: ""
ALLOWED_REALMS: ""

View file

@ -1,76 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ungleich-user
spec:
replicas: 2
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
# Migrations run before the app starts; migrate_locked serializes
# them when several pods start at once.
initContainers:
- name: migrate
image: ungleich-user
command: ["python", "manage.py", "migrate_locked"]
envFrom: &env
- configMapRef:
name: ungleich-user
- secretRef:
name: ungleich-user
securityContext: &containerSecurity
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts: &mounts
- name: tmp
mountPath: /tmp
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {memory: 256Mi}
containers:
- name: web
image: ungleich-user
envFrom: *env
ports:
- name: http
containerPort: 8000
securityContext: *containerSecurity
volumeMounts: *mounts
# Liveness has no dependencies, so an LDAP outage never restarts
# pods; readiness takes a pod out of the Service when the
# database or every LDAP server is unreachable.
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet: {path: /readyz, port: http}
periodSeconds: 10
timeoutSeconds: 10
failureThreshold: 3
startupProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 2
failureThreshold: 30
resources:
requests: {cpu: 100m, memory: 192Mi}
limits: {memory: 512Mi}
volumes:
- name: tmp
emptyDir:
sizeLimit: 64Mi

View file

@ -1,23 +0,0 @@
# TLS terminates here; the app trusts X-Forwarded-Proto
# (TRUST_X_FORWARDED_PROTO in the ConfigMap). Adjust the class, host and
# certificate secret to the cluster (e.g. cert-manager annotations).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ungleich-user
spec:
# ingressClassName: nginx
tls:
- hosts: ["account.ungleich.ch"]
secretName: ungleich-user-tls
rules:
- host: account.ungleich.ch
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ungleich-user
port:
name: http

View file

@ -1,25 +0,0 @@
# Base manifests for the ungleich account webapp.
#
# kubectl apply -k deploy/k8s/base # after creating the Secret
#
# The Secret "ungleich-user" is not part of this base; create it from
# secret.example.yaml (or your secret manager) before applying.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
labels:
- pairs:
app.kubernetes.io/name: ungleich-user
includeSelectors: true
resources:
- configmap.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- pdb.yaml
images:
- name: ungleich-user
newName: ungleich-user # set to your registry, e.g. registry.example.com/ungleich-user
newTag: latest # pin a release tag or digest in production

View file

@ -1,6 +0,0 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ungleich-user
spec:
minAvailable: 1

View file

@ -1,9 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: ungleich-user
spec:
ports:
- name: http
port: 80
targetPort: http

View file

@ -1,67 +0,0 @@
#!/usr/bin/env bash
# End-to-end check of a running dev overlay (see deploy/k8s/README.md):
# probes, registration via real SMTP (Mailpit), activation, login, and
# LDAP failover (ldap1 is scaled to zero and back).
#
# deploy/k8s/dev/e2e.sh
set -euo pipefail
NS=${NS:-ungleich-user-dev}
APP=http://localhost:8080
MAIL=http://localhost:8025
WORK=$(mktemp -d)
FAILED=0
PIDS=()
cleanup() {
kill "${PIDS[@]}" 2>/dev/null || true
kubectl -n "$NS" scale deploy/ldap1 --replicas=1 >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
check() { # check <name> <expected substring> <actual>
if [[ "$3" == *"$2"* ]]; then echo "PASS $1"; else echo "FAIL $1 (got: ${3:0:200})"; FAILED=1; fi
}
kubectl -n "$NS" port-forward svc/ungleich-user 8080:80 >/dev/null 2>&1 & PIDS+=($!); disown
kubectl -n "$NS" port-forward svc/mailpit 8025:8025 >/dev/null 2>&1 & PIDS+=($!); disown
for _ in $(seq 1 30); do curl -sf "$APP/healthz" >/dev/null 2>&1 && break; sleep 1; done
# Each "browser" gets its own cookie jar; post <jar> <path> <form data>
post() {
curl -s -c "$1" -b "$1" "$APP$2" -o /dev/null
local tok; tok=$(awk '/csrftoken/{print $7}' "$1")
curl -s -c "$1" -b "$1" -H "Referer: $APP$2" --data "csrfmiddlewaretoken=$tok&$3" "$APP$2"
}
check "liveness" '"status": "ok"' "$(curl -s "$APP/healthz")"
check "readiness" '"ldap": "ok"' "$(curl -s "$APP/readyz")"
USER=e2e-$RANDOM$RANDOM
PASS=e2e-pass-$RANDOM$RANDOM
curl -s -X DELETE "$MAIL/api/v1/messages" >/dev/null
check "register form" "Verify your email" \
"$(post "$WORK/a" /register/ "username=$USER&password1=$PASS&password2=$PASS&email=$USER@example.com&firstname=E2e&lastname=Test")"
MSG_ID=""
for _ in $(seq 1 10); do
MSG_ID=$(curl -s "$MAIL/api/v1/messages" | python3 -c 'import json,sys; m=json.load(sys.stdin)["messages"]; print(m[0]["ID"] if m else "")')
[[ -n "$MSG_ID" ]] && break; sleep 1
done
BODY=$(curl -s "$MAIL/api/v1/message/$MSG_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["Text"])')
LINK=$(grep -oE "$APP/activate/[^ \"<>]+" <<<"$BODY" | head -1 || true)
check "activation email via SMTP" "/activate/" "$LINK"
check "no password in email" "ok" "$([[ "$BODY" == *"$PASS"* ]] && echo leaked || echo ok)"
check "activation" "User $USER created" "$(curl -s "$LINK")"
check "login as new user" "Options for $USER" \
"$(post "$WORK/b" /index/ "username=$USER&password=$PASS")"
kubectl -n "$NS" scale deploy/ldap1 --replicas=0 >/dev/null
kubectl -n "$NS" wait --for=delete pod -l app=ldap1 --timeout=60s >/dev/null 2>&1 || true
check "login with ldap1 down (failover)" "Options for customer1" \
"$(post "$WORK/c" /index/ "username=customer1&password=customerpass")"
check "readiness with ldap1 down" '"ldap": "ok"' "$(curl -s "$APP/readyz")"
kubectl -n "$NS" scale deploy/ldap1 --replicas=1 >/dev/null
exit $FAILED

View file

@ -1,89 +0,0 @@
# Local test overlay (kind): the app plus throwaway LDAP, PostgreSQL and a
# mail catcher (Mailpit).
# Dev-only credentials, no persistence. See deploy/k8s/README.md.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ungleich-user-dev
resources:
- namespace.yaml
- ../base
- ldap.yaml
- postgres.yaml
- mailpit.yaml
secretGenerator:
- name: ungleich-user
options:
disableNameSuffixHash: true
literals:
- SECRET_KEY=dev-only-not-a-secret-dev-only-not-a-secret-dev-only
- LDAP_ADMIN_DN=cn=admin,dc=ungleich,dc=ch
- LDAP_ADMIN_PASSWORD=admin
- DATABASE_PASSWORD=dev-only
- ADMIN_SEED=JBSWY3DPEHPK3PXP
patches:
- target: {kind: ConfigMap, name: ungleich-user}
patch: |-
- op: replace
path: /data/ALLOWED_HOSTS
value: "localhost,127.0.0.1"
- op: replace
path: /data/CSRF_TRUSTED_ORIGINS
value: "http://localhost:8080"
- op: replace
path: /data/LDAPSERVER
value: "ldap://ldap1:389 ldap://ldap2:389"
- op: replace
path: /data/LDAP_USE_TLS
value: "False"
- op: replace
path: /data/DATABASE_HOST
value: "postgres"
- op: replace
path: /data/EMAIL_FROM_ADDRESS
value: "noreply@example.com"
- op: replace
path: /data/OTPSERVER
value: "http://otp.invalid/"
- op: replace
path: /data/ADMIN_NAME
value: "dev"
- op: replace
path: /data/ADMIN_REALM
value: "ungleich-admin"
- op: replace
path: /data/USER_REALM
value: "ungleich-auth"
- op: replace
path: /data/ALLOWED_REALMS
value: "ungleich-auth"
- op: replace
path: /data/EMAIL_HOST
value: "mailpit"
- op: replace
path: /data/EMAIL_PORT
value: "1025"
- op: replace
path: /data/EMAIL_USE_TLS
value: "False"
- op: add
path: /data/SESSION_COOKIE_SECURE
value: "False"
- op: add
path: /data/CSRF_COOKIE_SECURE
value: "False"
- target: {kind: Ingress, name: ungleich-user}
patch: |-
$patch: delete
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ungleich-user
images:
- name: ungleich-user
newName: ungleich-user
newTag: dev

View file

@ -1,59 +0,0 @@
# Two independent dev OpenLDAP servers (dev/ldap image, same seed data) so
# the app's LDAP failover can be exercised. They do not replicate.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ldap1
spec:
selector:
matchLabels: {app: ldap1}
template:
metadata:
labels: {app: ldap1}
spec:
containers:
- name: slapd
image: ungleich-user-dev-ldap:2.6
imagePullPolicy: Never
env:
- {name: LDAP_ADMIN_PASSWORD, value: admin}
ports:
- {containerPort: 389}
---
apiVersion: v1
kind: Service
metadata:
name: ldap1
spec:
selector: {app: ldap1}
ports:
- {port: 389}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ldap2
spec:
selector:
matchLabels: {app: ldap2}
template:
metadata:
labels: {app: ldap2}
spec:
containers:
- name: slapd
image: ungleich-user-dev-ldap:2.6
imagePullPolicy: Never
env:
- {name: LDAP_ADMIN_PASSWORD, value: admin}
ports:
- {containerPort: 389}
---
apiVersion: v1
kind: Service
metadata:
name: ldap2
spec:
selector: {app: ldap2}
ports:
- {port: 389}

View file

@ -1,29 +0,0 @@
# Catches outgoing mail in the dev overlay. Web UI / API on port 8025:
# kubectl -n ungleich-user-dev port-forward svc/mailpit 8025
apiVersion: apps/v1
kind: Deployment
metadata:
name: mailpit
spec:
selector:
matchLabels: {app: mailpit}
template:
metadata:
labels: {app: mailpit}
spec:
containers:
- name: mailpit
image: axllent/mailpit:v1.27
ports:
- {name: smtp, containerPort: 1025}
- {name: http, containerPort: 8025}
---
apiVersion: v1
kind: Service
metadata:
name: mailpit
spec:
selector: {app: mailpit}
ports:
- {name: smtp, port: 1025}
- {name: http, port: 8025}

View file

@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: ungleich-user-dev

View file

@ -1,39 +0,0 @@
# Throwaway PostgreSQL for the dev overlay (emptyDir: data is lost on restart).
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
selector:
matchLabels: {app: postgres}
template:
metadata:
labels: {app: postgres}
spec:
containers:
- name: postgres
image: postgres:18-alpine
env:
- {name: POSTGRES_DB, value: ungleich_user}
- {name: POSTGRES_USER, value: ungleich_user}
- {name: POSTGRES_PASSWORD, value: dev-only}
ports:
- {containerPort: 5432}
readinessProbe:
exec:
command: ["pg_isready", "-U", "ungleich_user"]
periodSeconds: 3
volumeMounts:
- {name: data, mountPath: /var/lib/postgresql}
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector: {app: postgres}
ports:
- {port: 5432}

View file

@ -1,22 +0,0 @@
# Example only; do not commit real values. Create the Secret with your
# secret manager, or fill this in outside git and apply it:
#
# kubectl create secret generic ungleich-user \
# --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
# --from-literal=LDAP_ADMIN_DN='...' \
# --from-literal=LDAP_ADMIN_PASSWORD='...' \
# --from-literal=DATABASE_PASSWORD='...' \
# --from-literal=ADMIN_SEED='...'
apiVersion: v1
kind: Secret
metadata:
name: ungleich-user
type: Opaque
stringData:
SECRET_KEY: ""
LDAP_ADMIN_DN: ""
LDAP_ADMIN_PASSWORD: ""
DATABASE_PASSWORD: ""
ADMIN_SEED: ""
# EMAIL_HOST_USER: ""
# EMAIL_HOST_PASSWORD: ""

View file

@ -1,32 +0,0 @@
# Local development settings for the dev LDAP container (docker-compose.dev.yml).
# Dev-only credentials, not secrets. Use with: cp dev/env.dev .env
LDAPSERVER="ldap://localhost:3389"
LDAP_ADMIN_DN="cn=admin,dc=ungleich,dc=ch"
LDAP_ADMIN_PASSWORD="admin"
LDAP_CUSTOMER_DN="ou=customer,dc=ungleich,dc=ch"
LDAP_CUSTOMER_GROUP_ID=10004
LDAP_DEFAULT_START_UID=10000
# Space separated list of search bases for users
LDAPSEARCH="ou=customer,dc=ungleich,dc=ch ou=users,dc=ungleich,dc=ch"
ENTIRE_SEARCH_BASE="dc=ungleich,dc=ch"
SECRET_KEY="dev-only-not-a-secret"
ALLOWED_HOSTS="localhost,127.0.0.1,testserver"
DEBUG=True
EMAIL_FROM_ADDRESS="noreply@example.com"
# OTP server: not run locally; tests mock HTTP calls to it.
ALLOWED_REALMS="ungleich-admin,ungleich-auth"
OTPSERVER="http://localhost:9999/"
ADMIN_SEED="JBSWY3DPEHPK3PXP"
ADMIN_NAME="dev"
ADMIN_REALM="ungleich-admin"
USER_REALM="ungleich-auth"
# Optional: use the dev PostgreSQL from docker-compose.dev.yml instead of SQLite
#DATABASE_HOST=localhost
#DATABASE_PORT=5433
#DATABASE_PASSWORD=dev-only

View file

@ -1,24 +0,0 @@
# Local OpenLDAP for development and tests only. Not for production.
#
# Alpine 3.24 ships OpenLDAP 2.6.15. No sshd is installed here; the
# "openssh-lpk" bit below is only an LDAP schema for the ldapPublicKey
# objectClass that the app writes.
FROM alpine:3.24
RUN apk add --no-cache \
openldap \
openldap-back-mdb \
openldap-clients \
openssl \
&& mkdir -p /run/openldap /var/lib/openldap/openldap-data /etc/openldap/slapd.d \
&& rm -rf /etc/openldap/slapd.conf /etc/openldap/slapd.ldif
COPY config.ldif openssh-lpk.ldif seed.ldif /bootstrap/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 389 636
HEALTHCHECK --interval=5s --timeout=3s --retries=10 \
CMD ldapsearch -x -H ldap://localhost -b "" -s base namingContexts | grep -q "dc=ungleich,dc=ch" || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,57 +0,0 @@
dn: cn=config
objectClass: olcGlobal
cn: config
olcArgsFile: /run/openldap/slapd.args
olcPidFile: /run/openldap/slapd.pid
olcTLSCACertificateFile: /etc/openldap/tls/ca.crt
olcTLSCertificateFile: /etc/openldap/tls/server.crt
olcTLSCertificateKeyFile: /etc/openldap/tls/server.key
olcTLSProtocolMin: 3.3
dn: cn=module,cn=config
objectClass: olcModuleList
cn: module
olcModulepath: /usr/lib/openldap
olcModuleload: back_mdb.so
dn: cn=schema,cn=config
objectClass: olcSchemaConfig
cn: schema
include: file:///etc/openldap/schema/core.ldif
include: file:///etc/openldap/schema/cosine.ldif
include: file:///etc/openldap/schema/inetorgperson.ldif
include: file:///etc/openldap/schema/nis.ldif
dn: olcDatabase={-1}frontend,cn=config
objectClass: olcDatabaseConfig
objectClass: olcFrontendConfig
olcDatabase: {-1}frontend
olcPasswordHash: {SSHA}
dn: olcDatabase={0}config,cn=config
objectClass: olcDatabaseConfig
olcDatabase: {0}config
olcAccess: {0}to * by * none
dn: olcDatabase={1}mdb,cn=config
objectClass: olcDatabaseConfig
objectClass: olcMdbConfig
olcDatabase: {1}mdb
olcSuffix: dc=ungleich,dc=ch
olcRootDN: cn=admin,dc=ungleich,dc=ch
olcRootPW: @ADMIN_PW_HASH@
olcDbDirectory: /var/lib/openldap/openldap-data
olcDbMaxSize: 1073741824
olcDbIndex: objectClass eq
olcDbIndex: uid eq
olcDbIndex: uidNumber eq
olcDbIndex: mail eq
olcAccess: {0}to attrs=userPassword
by self write
by anonymous auth
by * none
olcAccess: {1}to *
by self write
by users read
by * none

View file

@ -1,50 +0,0 @@
#!/bin/sh
# Builds a fresh directory on every container start, so recreating the
# container (`docker compose up -d --force-recreate ldap`) resets all data.
set -eu
CONF=/etc/openldap/slapd.d
DATA=/var/lib/openldap/openldap-data
rm -rf "${CONF:?}"/* "${DATA:?}"/*
# Throwaway CA and server certificate, new on every start. The CA cert is
# copied to /export (mounted from the host) so clients can verify the
# server. 127.0.0.1 is left out on purpose: tests use it to prove that
# hostname verification rejects a name the certificate does not cover.
TLS=/etc/openldap/tls
mkdir -p "$TLS"
# Full CA extensions: Python 3.13+ verifies strictly by default and
# rejects a CA without keyUsage.
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 3650 \
-subj "/CN=ungleich-user dev CA" -keyout "$TLS/ca.key" -out "$TLS/ca.crt" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" 2>/dev/null
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-subj "/CN=localhost" -keyout "$TLS/server.key" -out "$TLS/server.csr" 2>/dev/null
printf 'subjectAltName=%s\nextendedKeyUsage=serverAuth\nkeyUsage=critical,digitalSignature\nbasicConstraints=CA:FALSE\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid\n' \
"DNS:localhost,DNS:ldap,DNS:ldap1,DNS:ldap2,DNS:v6-ldap,IP:::1" > "$TLS/ext.cnf"
openssl x509 -req -in "$TLS/server.csr" -CA "$TLS/ca.crt" -CAkey "$TLS/ca.key" \
-CAcreateserial -days 3650 -extfile "$TLS/ext.cnf" -out "$TLS/server.crt" 2>/dev/null
rm -f "$TLS/ca.key"
if [ -d /export ]; then cp "$TLS/ca.crt" /export/ca.crt; fi
ADMIN_PW_HASH=$(slappasswd -s "${LDAP_ADMIN_PASSWORD:-admin}")
# cn=config: modules, schemas, database, ACLs
sed "s|@ADMIN_PW_HASH@|${ADMIN_PW_HASH}|" /bootstrap/config.ldif > /tmp/config.ldif
slapadd -n 0 -F "$CONF" -l /tmp/config.ldif
slapadd -n 0 -F "$CONF" -l /bootstrap/openssh-lpk.ldif
# Directory tree and seed users
slapadd -n 1 -F "$CONF" -l /bootstrap/seed.ldif
chown -R ldap:ldap "$CONF" "$DATA" /run/openldap "$TLS"
# slapd sizes its descriptor table by the open-file limit; under some
# runtimes (kind/containerd) that is ~10^9, and slapd is OOM-killed.
ulimit -n 4096
# -d keeps slapd in the foreground; "stats" logs every operation to stdout.
exec slapd -u ldap -g ldap -F "$CONF" -h "ldap:/// ldaps:///" -d "${SLAPD_LOGLEVEL:-stats}"

View file

@ -1,13 +0,0 @@
# openssh-lpk schema: provides the ldapPublicKey objectClass and the
# sshPublicKey attribute that the app sets on new accounts.
dn: cn=openssh-lpk,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: openssh-lpk
olcAttributeTypes: ( 1.3.6.1.4.1.24552.500.1.1.1.13 NAME 'sshPublicKey'
DESC 'MANDATORY: OpenSSH Public key'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )
olcObjectClasses: ( 1.3.6.1.4.1.24552.500.1.1.2.0 NAME 'ldapPublicKey'
SUP top AUXILIARY
DESC 'MANDATORY: OpenSSH LPK objectclass'
MAY ( sshPublicKey $ uid ) )

View file

@ -1,57 +0,0 @@
# Mirrors the production layout (from sample entries ungleich provided):
# dc=ungleich,dc=ch with customers in ou=customer (singular) and staff in
# ou=users. Customers have gidNumber 10004, staff 10002.
# Passwords here are dev-only; slapadd stores them as given. Users created
# through the app get {SSHA} hashes.
dn: dc=ungleich,dc=ch
objectClass: top
objectClass: dcObject
objectClass: organization
o: ungleich
dc: ungleich
dn: ou=customer,dc=ungleich,dc=ch
objectClass: organizationalUnit
ou: customer
dn: ou=users,dc=ungleich,dc=ch
objectClass: organizationalUnit
ou: users
# Staff entry, shaped like production's ou=users entries
dn: uid=staff1,ou=users,dc=ungleich,dc=ch
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: posixAccount
objectClass: ldapPublicKey
uid: staff1
cn: staff1
sn: Staff
givenName: Sam
mail: staff1@example.com
uidNumber: 10000
gidNumber: 10002
homeDirectory: /home/staff1
loginShell: /bin/bash
userPassword: staffpass
sshPublicKey: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDevOnlyNotARealKeyDevOnlyNotARealKey00 staff1-dev
# Customer entry, shaped like production's ou=customer entries (and like
# what the app creates on registration)
dn: uid=customer1,ou=customer,dc=ungleich,dc=ch
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: ldapPublicKey
uid: customer1
cn: customer1
sn: Customer
givenName: Cora
displayName: Cora Customer
mail: customer1@example.com
uidNumber: 10001
gidNumber: 10004
homeDirectory: /home/customer1
loginShell: /bin/bash
userPassword: customerpass

View file

@ -1,34 +0,0 @@
# Dev dependencies only. The Django app runs natively (no container) and
# talks to LDAP on ldap://localhost:3389 (StartTLS) and ldaps://localhost:3636,
# and, if DATABASE_HOST=localhost is set, to PostgreSQL on localhost:5433
# (SQLite is used otherwise). The LDAP CA certificate is written to
# dev/ldap/export/ca.crt on every start.
#
# docker compose -f docker-compose.dev.yml up -d --build
# docker compose -f docker-compose.dev.yml up -d --force-recreate ldap # reset data
services:
ldap:
build: dev/ldap
image: ungleich-user-dev-ldap:2.6
environment:
LDAP_ADMIN_PASSWORD: admin
ports:
- "127.0.0.1:3389:389"
- "127.0.0.1:3636:636"
- "[::1]:3389:389"
- "[::1]:3636:636"
volumes:
- ./dev/ldap/export:/export
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: ungleich_user
POSTGRES_USER: ungleich_user
POSTGRES_PASSWORD: dev-only
ports:
- "127.0.0.1:5433:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "ungleich_user"]
interval: 3s
retries: 20

227
docker.md
View file

@ -1,227 +0,0 @@
# Deploying the account webapp
This is the app behind account.ungleich.ch. It ships as one Docker image,
which you configure only through environment variables. Database migrations
are a separate command that you run before starting (or updating) the app.
The image never migrates on its own.
There are three ways to run it below: plain `docker run`, Docker Compose,
and Kubernetes with Helm. They all use the same image and the same
variables.
## What the image does
- **Serves the app** with gunicorn on port **8000**, IPv6 and IPv4.
- **Runs as uid 10001.** It works with a read-only root filesystem as long
as `/tmp` is writable.
- **Serves its own static files,** so you don't need a web server in front
of it inside the pod or container. TLS is the ingress's or proxy's job.
- **Logs to stdout and stderr** only.
- **Health endpoints:**
- `GET /healthz`: the process is up. It has no dependencies, so use it
for liveness.
- `GET /readyz`: the database answers and an LDAP server accepts the admin
login. It returns 503 with the failing check otherwise. Use it for
readiness.
Both answer on any `Host` header, so probes don't need to match
`ALLOWED_HOSTS`.
- **Two commands:**
- `gunicorn dal.wsgi:application`, the default: runs the app.
- `python manage.py migrate_locked`: applies database migrations. It takes
a PostgreSQL lock first, so it's safe even if several copies start at
once.
Build it from the repository root:
```sh
docker build -t ungleich-user:1.0.0 .
```
## Configuration
Copy `.env.example` to `.env` and fill it in. `.env.example` lists every
variable. These are the ones that matter.
### Required
The app refuses to start, and names the missing variable, if one of these
isn't set.
| Variable | What it is |
|---|---|
| `SECRET_KEY` | **Secret.** A long random string, for example from `python3 -c 'import secrets; print(secrets.token_urlsafe(50))'`. Keep it stable: changing it logs everyone out. |
| `LDAPSERVER` | The LDAP server(s), space separated: `ldaps://ldap1.ungleich.ch ldaps://ldap2.ungleich.ch`. The app uses the first one that answers. |
| `LDAP_ADMIN_DN` | The DN the app uses to search the directory and to create or change customer entries. |
| `LDAP_ADMIN_PASSWORD` | **Secret.** Its password. |
| `EMAIL_FROM_ADDRESS` | The sender of activation and password-reset emails. |
### Usually set in production
| Variable | When you need it |
|---|---|
| `TRUST_X_FORWARDED_PROTO=True` | Behind an HTTPS ingress or proxy. Without it, links in emails start with `http://`. Only set it if the proxy always sets `X-Forwarded-Proto`. |
| `DATABASE_HOST`, `DATABASE_PASSWORD` (secret), optionally `DATABASE_NAME`, `DATABASE_USER` (both default to `ungleich_user`), `DATABASE_PORT`, `DATABASE_SSLMODE` | PostgreSQL. **Required for more than one replica**, because sessions, reset tokens and pending registrations live in the database. Without `DATABASE_HOST` the app uses SQLite at `/data/db.sqlite3`: fine for a single container with a volume on `/data`. |
| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD` (secret) | The mail relay. The default is `localhost:25` with STARTTLS. |
| `OTPSERVER`, then `ADMIN_SEED` (secret), `ADMIN_NAME`, `ADMIN_REALM`, `USER_REALM`, `ALLOWED_REALMS` | The OTP integration. Optional: without `OTPSERVER`, new accounts get no OTP seed and the seed pages say OTP isn't configured. If `OTPSERVER` is set, the next four are required. |
### Already defaulted to production
Leave these out unless you need something different.
| Variable | Default |
|---|---|
| `ALLOWED_HOSTS` | `account.ungleich.ch` (comma separated) |
| `CSRF_TRUSTED_ORIGINS` | `https://` plus each allowed host |
| `ENTIRE_SEARCH_BASE` | `dc=ungleich,dc=ch`. For a different tree (such as the test server, `dc=my-domain,dc=com`), this is usually the only layout setting you change. |
| `LDAP_CUSTOMER_DN` | `ou=customer,<ENTIRE_SEARCH_BASE>`: where registrations are created |
| `LDAPSEARCH` | `ou=customer,<base> ou=users,<base>`: where logins are looked up |
| `LDAP_CUSTOMER_GROUP_ID` | `10004` |
| `LDAP_DEFAULT_START_UID` | `10000`. New uidNumbers always go above the highest existing one. |
| `LDAP_USER_OBJECT_CLASSES` | `inetOrgPerson posixAccount ldapPublicKey`. Drop `ldapPublicKey` on a server without the openssh-lpk schema. |
| `LDAP_TLS_VERIFY`, `LDAP_CA_FILE` | Certificates are verified against the system trust store. Set `LDAP_CA_FILE` (a path inside the container) for a private CA. |
Everything else (log levels, timeouts, gunicorn worker counts, HSTS) is in
`.env.example`, with its default.
**Secrets, all in one place:** `SECRET_KEY`, `LDAP_ADMIN_PASSWORD`,
`DATABASE_PASSWORD`, `EMAIL_HOST_PASSWORD`, `ADMIN_SEED`.
## Option 1: plain `docker run`
The simplest setup: one container, SQLite on a named volume.
```sh
docker volume create ungleich-user-data
# 1. migrations (once, and again after every update)
docker run --rm --env-file .env -v ungleich-user-data:/data \
ungleich-user:1.0.0 python manage.py migrate_locked
# 2. the app
docker run -d --name ungleich-user --restart unless-stopped \
--env-file .env -v ungleich-user-data:/data -p 8000:8000 \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
ungleich-user:1.0.0
```
For PostgreSQL instead, put `DATABASE_HOST` and `DATABASE_PASSWORD` in `.env`
and leave out the volume.
To update: build or pull the new tag, run step 1 with it, then replace the
container (`docker rm -f ungleich-user`, then step 2 with the new tag).
## Option 2: Docker Compose
`compose.yaml` runs PostgreSQL, a one-off `migrate` service and the app.
The app starts only once the migrations have succeeded.
```sh
cp .env.example .env # fill in the required values and DATABASE_PASSWORD
docker compose up -d --build
docker compose ps # db healthy, migrate "exited (0)", web healthy
```
- **Configuration:** comes from `.env`. Compose sets `DATABASE_HOST` itself.
`DATABASE_PASSWORD` is used both to create the database and by the app.
- **Port:** the app listens on 8000; set `PORT` in `.env` to publish it
elsewhere.
- **Updating:** `git pull && docker compose up -d --build`. Migrations run
again automatically before the new app starts.
- **Data:** lives in the `pgdata` volume. Back it up with
`docker compose exec db pg_dump -U ungleich_user ungleich_user > backup.sql`.
- **Logs:** `docker compose logs -f web`.
## Option 3: Kubernetes with Helm
This works like dynamicweb: the chart injects the configuration as
environment variables, and migrations run as their own step, outside the
app's pods.
**Environment.** Put the five secrets above in a Secret and everything else
in a ConfigMap, then load both into the container with `envFrom`. The
minimum a chart has to set is the five required variables, plus
`DATABASE_HOST` and `DATABASE_PASSWORD` for more than one replica, and
`TRUST_X_FORWARDED_PROTO=True` behind the ingress.
**Migrations.** Run `python manage.py migrate_locked` with the same image and
the same environment before the new pods start. A Helm hook Job is the usual
way:
```yaml
apiVersion: batch/v1
kind: Job
metadata:
name: ungleich-user-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/ungleich-user:1.0.0
command: ["python", "manage.py", "migrate_locked"]
envFrom:
- secretRef: {name: ungleich-user}
- configMapRef: {name: ungleich-user}
```
One catch: a `pre-install` hook runs before the chart's regular resources
are created. If the chart itself creates the Secret and ConfigMap, give
them a hook annotation with a lower `helm.sh/hook-weight` so they exist
first. Or create them outside the chart. An init container in the
Deployment works too. `migrate_locked` makes it safe for several pods to
run it at the same time.
**The app's pods** need:
- **Port and probes:** container port `8000`; liveness probe `/healthz`;
readiness probe `/readyz` (allow it about 10 seconds, since it talks to
LDAP).
- **Security context:** `runAsUser: 10001`, `runAsNonRoot: true`,
`readOnlyRootFilesystem: true`, `allowPrivilegeEscalation: false`, all
capabilities dropped.
- **Volumes:** an `emptyDir` mounted on `/tmp`.
- **Resources:** about 100m CPU and 192 MiB to request; a 512 MiB memory
limit is plenty.
IPv6-only clusters are fine. The app is dual-stack, and its LDAP,
PostgreSQL and mail connections all work over IPv6.
`deploy/k8s/base/` contains a complete, tested version of this setup as
plain Kubernetes manifests (kustomize). Copy from it freely.
## After deploying
1. `curl https://account.ungleich.ch/healthz` should return `{"status": "ok"}`.
2. `/readyz` should show `"database": "ok", "ldap": "ok"`.
3. Log in with an existing account, and register a test account to check that
mail arrives and the activation link works.
## When something is wrong
| You see | Likely cause |
|---|---|
| The container exits at once with `X not found. Declare it as envvar` | A required variable is missing; the message names it. |
| `400 Bad Request` for every page | The hostname isn't in `ALLOWED_HOSTS`. |
| `403 CSRF verification failed` on login | The address in the browser isn't a trusted origin, usually because the site is reached under a hostname that isn't in `ALLOWED_HOSTS`, or because `CSRF_TRUSTED_ORIGINS` is set explicitly without it. |
| Links in emails start with `http://` | `TRUST_X_FORWARDED_PROTO=True` is missing behind the ingress. |
| `/readyz` reports `"ldap": "failed"` | The app's log names the reason: a wrong admin DN or password, a certificate it can't verify (set `LDAP_CA_FILE` for a private CA), or no route to the LDAP servers. |
| `/readyz` reports `"database": "failed"` | Wrong `DATABASE_*` values, or the database is unreachable. |
| Pages fail with `no such table` or `relation … does not exist` | The migrations haven't run for this release: run `migrate_locked`. |
| Registration fails with an `objectClass` error | The LDAP server lacks the openssh-lpk schema. Load it, or set `LDAP_USER_OBJECT_CLASSES="inetOrgPerson posixAccount"`. |
**One-time note for the first release:** activation emails sent by the old
version contain links in a format the new version no longer accepts. People
who signed up just before the switch need to register again.

View file

@ -1,16 +0,0 @@
"""Gunicorn settings; each can be overridden with GUNICORN_* env vars."""
import os
# [::] is dual-stack on Linux: it accepts IPv6 and IPv4. 0.0.0.0 would be
# IPv4 only and unreachable on IPv6-only clusters.
bind = os.environ.get("GUNICORN_BIND", "[::]:8000")
# Threaded workers: requests mostly wait on LDAP, SMTP and the OTP server.
worker_class = "gthread"
workers = int(os.environ.get("GUNICORN_WORKERS", "2"))
threads = int(os.environ.get("GUNICORN_THREADS", "4"))
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "60"))
graceful_timeout = 20
# Worker heartbeat files; /dev/shm keeps a read-only root filesystem working.
worker_tmp_dir = "/dev/shm"
accesslog = "-"
errorlog = "-"

350
nameko-func.py Normal file
View file

@ -0,0 +1,350 @@
from nameko.events import EventDispatcher, event_handler
from nameko.rpc import rpc
from configparser import ConfigParser
from ldap3 import Server, ServerPool, Connection, ObjectDef, AttrDef, Reader, Writer
from datetime import datetime
# For testing
from random import randint
# Read the config in the nameko.conf
config = ConfigParser()
config.read('nameko.conf')
# Sanity check for config
try:
mult_server = int(config['LDAP']['SERVERMULTIPLE'])
# SERVERMULTIPLE is set to something not a number
except:
exit("[LDAP] SERVERMULTIPLE has to be an integer >= 1")
# less than one server is not a sensible option
if mult_server < 1:
exit("[LDAP] SERVERMULTIPLE has to be an integer >= 1")
# Function to setup the server or serverpool
def ldapservers():
# Just one server, no need for a pool
if mult_server == 1:
ldapserver = Server(config['LDAP']['LDAPSERVER1'], use_ssl=True)
return ldapserver
# Multiple servers, set up a pool
else:
ldapserver = ServerPool(None)
for x in range(1, (mult_server+1)):
ins = Server(config['LDAP']['LDAPSERVER' + str(x)], use_ssl=True)
ldapserver.add(ins)
return ldapserver
# Since there's no reason why someone in ou=users shouldn't use the service,
# here's the helper function to check whether an uid is in ou=customers or
# ou=users
# returns the full dn
def user_or_customer(uid):
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True))
conn.bind()
search_customers = conn.search('ou=customers,dc=ungleich,dc=ch', '(%s)' % uid)
if search_customers:
conn.unbind()
return '%s,ou=customers,dc=ungleich,dc=ch' % uid
search_users = conn.search('ou=users,dc=ungleich,dc=ch', '(%s)' % uid)
if search_users:
conn.unbind()
return '%s,ou=users,dc=ungleich,dc=ch' % uid
conn.unbind()
return False
# Get the objectclasses
def objclasses(rdn, uid, connection):
# search for objectClasses
connection.search(rdn, '(%s)' % uid, attributes=['objectClass'])
objclass = []
# get the relevant data
tmp = connection.entries[0]['objectClass']
# This one sets up the array
for y in tmp:
objclass.append(y)
# return the array containing the objectClasses, like ['inetOrgPerson', 'posixAccount', 'ldapPublicKey']
return objclass
# checks if a user already exists in the LDAP
class UserLookUp(object):
name = "userlookup"
dispatch = EventDispatcher()
@rpc
def lookup(self, user):
# Setup the search parameter and connect to LDAP
LDAP_UID = 'uid=%s' % user
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True))
conn.bind()
# Strange result. It keeps complaining LDAP_UID not set if I try to directly
# substitute x and y to the if
# Searches for user in ou=customers and ou=users
x = conn.search('ou=customers,dc=ungleich,dc=ch', '(%s)' % LDAP_UID)
y = conn.search('ou=users,dc=ungleich,dc=ch', '(%s)' % LDAP_UID)
if x or y:
# return conn.entries[0] for first search result since we can assume uid is unique
self.dispatch('ldap', '%s [Info: UserLookUp] Searched for %s and found it\n' % (datetime.now(), LDAP_UID) )
conn.unbind()
# return True since the user is already in LDAP
return True
# User not in LDAP, so just close it down, write the log and return False
else:
conn.unbind()
self.dispatch('ldap', '%s [Info: UserLookUp] Searched for %s and not found it.\n' % (datetime.now(), LDAP_UID) )
return False
# Create a user in the LDAP. Assumes the checks are already in place for existing users
class CreateUser(object):
name = "createuser"
dispatch = EventDispatcher()
@rpc
def create_user(self, user, password, firstname, lastname, email):
# Creates a user with some basic data
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP','LDAPMANAGERPASSWORD', raw=True))
if not conn.bind():
self.dispatch('ldap', '%s [Error CreateUser] Could not connect to LDAPserver\n' % datetime.now() )
return "Could not connect to LDAP Server."
# set objectClasses for the new user
obj_new_user = ObjectDef(['inetOrgPerson', 'posixAccount', 'ldapPublicKey'], conn)
w = Writer(conn, obj_new_user)
dn = 'uid=%s,ou=users,dc=ungleich,dc=ch' % user
w.new(dn)
# Filling in some of the data
# required attributes are sn, cn, homeDirectory, uid (already handled by dn), uidNumber, gidNumber
w[0].givenName = firstname
w[0].sn = lastname
w[0].cn = firstname + " " + lastname
w[0].mail = email
w[0].userPassword = password
w[0].homeDirectory = '/home/%s' % user
# Set uidNumber as last used uidNumber+1
w[0].uidNumber = self.get_new_uid_number(conn)
# gidNumber for users created by userservice, nice and clear
w[0].gidNumber = 10004
if not w.commit():
conn.unbind()
self.dispatch('ldap', '%s [Error CreateUser] Could not write new user %s to LDAP DB\n' % (datetime.now(), dn) )
return "Couldn't write data to the LDAP Server."
conn.unbind()
self.dispatch('ldap', '%s [Info CreateUser] %s created.\n' % (datetime.now(), dn) )
return True
# Function to get the next uid number. Not elegant, but LAM does it too and didn't really find anything
# nicer. The sorted() seems to be quite efficient, so it shouldn't take too long even on larger arrays
def get_new_uid_number(self, conn):
conn.search('dc=ungleich,dc=ch', '(&(objectClass=posixAccount)(uidNumber=*))', attributes = [ 'uidNumber' ])
newuid = 0
uidlist = []
for c in conn.response:
uidlist.append(c['attributes']['uidNumber'])
# New uid is highest old uidnumber plus one
newuid = (sorted(uidlist)[len(uidlist)-1] + 1)
return newuid
# Returns some basic data from an user
class GetUserData(object):
name = "getuserdata"
dispatch = EventDispatcher()
@rpc
def get_data(self, user):
# Setup the search parameter and connect to LDAP
LDAP_UID = 'uid=%s' % user
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True))
conn.bind()
if not conn.bound:
self.dispatch('ldap', '%s [Error GetUserData] Could not connect to LDAP server.\n' % datetime.now() )
return ("error", "Could not connect to LDAP server.", "", "")
rdn = user_or_customer(LDAP_UID)
if rdn == False:
conn.unbind()
self.dispatch('ldap', '%s [Info GetUserData] Could not find user %s\n' % (datetime.now(), LDAP_UID) )
return ("error", "Could not find the user.", "", "")
# Workaround because not all users have the same objectClasses
objclass = objclasses(rdn, LDAP_UID, conn)
obj = ObjectDef(objclass, conn)
# The Reader gets the data for the user
r = Reader(conn, obj, rdn)
r.search()
# Since the DN is basically confirmed by user_or_customer() it shouldn't throw an exception, but better check
try:
x = r[0].sn
except:
conn.unbind()
self.dispatch('ldap', '%s [Error GetUserData] Could not open Reader for %s\n' % (datetime.now(), rdn) )
return ("error", "Could not read data for user.", "", "")
# Putting the results into strings and then clean it up a bit if some attribute is not set in LDAP
(firstname, lastname, email) = (str(r[0].givenName), str(r[0].sn), str(r[0].mail))
if firstname == '[]':
firstname = 'No firstname given'
if lastname == '[]':
lastname = 'No lastname given'
if email == '[]':
email = 'No email given'
conn.unbind()
self.dispatch('ldap', '%s [Info GetUserData] Got data for %s Firstname: %s Lastname: %s Email: %s\n' % (datetime.now(), rdn, firstname, lastname, email) )
return ("OK", firstname, lastname, email)
# change some (firstname, lastname, email) data for the user
class ChangeUserData(object):
name = "changeuserdata"
dispatch = EventDispatcher()
@rpc
def change_data(self, user, firstname, lastname, email):
LDAP_UID = 'uid=%s' % user
server = ldapservers()
# Establish connection with a user who can change the data
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True))
if not conn.bind():
self.dispatch('ldap', '%s [Error ChangeUserData] Could not connect to LDAP server.\n' % datetime.now() )
return "Could not connect to LDAP server."
# get the DN of the user
rdn = user_or_customer(LDAP_UID)
if rdn == False:
conn.unbind()
self.dispatch('ldap', '%s [Info ChangeUserData] User with %s not found.\n' % (datetime.now(), LDAP_UID) )
return "Could not find user."
# Fix because not every user has the same objectClasses
objclass = objclasses(rdn, LDAP_UID, conn)
# Set up a reader for the user
obj = ObjectDef(objclass, conn)
r = Reader(conn, obj, rdn)
r.search()
# Again, user_or_customer() should prevent it from throwing an exception because it's a confirmed user
try:
x = r[0].sn
except:
conn.unbind()
self.dispatch('ldap', '%s [Error ChangeUserData] Could not open Reader for %s\n' % (datetime.now(), rdn) )
return "Could not open the data of user."
# Opens a Writer instance prefilled with the old data
# We could check if something has changed, but since the form takes the old data as standard values, let's
# just update the relevant attributes
w = Writer.from_cursor(r)
w[0].sn = lastname
w[0].cn = firstname + " " + lastname
w[0].givenName = firstname
w[0].mail = email
# check if the data is written
if not w.commit():
conn.unbind()
self.dispatch('ldap', '%s [Error ChangeUserData] Could not write changes for %s\n' % (datetime.now(), rdn) )
return "Could not write changes for user."
conn.unbind()
self.dispatch('ldap', '%s [Info ChangeUserData] Changed data for %s Firstname: %s Lastname: %s Email: %s\n' % (datetime.now(), rdn, firstname, lastname, email) )
return True
# change the password for the user
class ChangePassword(object):
name = "changepassword"
dispatch = EventDispatcher()
@rpc
def change_password(self, user, newpassword):
LDAP_UID = 'uid=%s' % user
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True))
if not conn.bind():
self.dispatch('ldap', '%s [Error ChangePassword] Could not connect to LDAP server.\n' % datetime.now() )
return "Could not connect to LDAP server."
# check if uid=user is in either ou=customers or ou=users
rdn = user_or_customer(LDAP_UID)
if rdn == False:
conn.unbind()
self.dispatch('ldap', '%s [Error ChangePassword] Could not find user %s\n' % (datetime.now(), LDAP_UID) )
return "Could not find the user."
# Plus not everyone has the same objectClasses, so workaround
objclass = objclasses(rdn, LDAP_UID, conn)
obj = ObjectDef(objclass, conn)
# Set up a Reader for the DN
r = Reader(conn, obj, rdn)
r.search()
# Shouldn't throw an exception, since the user is confirmed to be there
try:
x = r[0].sn
except:
conn.unbind()
self.dispatch('ldap', '%s [Error ChangePassword] Could not open Reader for %s\n' % (datetime.now(), rdn) )
return "Could not open the data for the user."
# Set up the writer and overwrite the attribute with the new password
w = Writer.from_cursor(r)
w[0].userPassword = newpassword
# Check to see if the change has gone through
if not w.commit():
conn.unbind()
self.dispatch('ldap', '%s [Error ChangePassword] Could not write data for %s\n' % (datetime.now(), rdn) )
return "Could not write data for the user."
conn.unbind()
self.dispatch('ldap', '%s [Info ChangePassword] Password changed for %s\n' % (datetime.now(), rdn) )
return True
# Deletes a user from LDAP
class DeleteUser(object):
name = "deleteuser"
dispatch = EventDispatcher()
@rpc
def delete_user(self, user):
LDAP_UID = 'uid=%s' % user
server = ldapservers()
conn = Connection(server, config['LDAP']['LDAPMANAGER'], config.get('LDAP', 'LDAPMANAGERPASSWORD', raw=True))
conn.bind()
if not conn.bound:
self.dispatch('ldap', '%s [Error DeleteUser] Could not connect to LDAP server.\n' % datetime.now() )
return "Could not connect to LDAP server."
# again, check whether the uid= is in ou=users or ou=customers
dn = user_or_customer(LDAP_UID)
if dn == False:
conn.unbind()
self.dispatch('ldap', '%s [Error DeleteUser] Could not find the user %s\n' % (datetime.now(), LDAP_UID) )
return "Could not find the user."
# Check if the delete was successfull
deleted = conn.delete(dn)
if not deleted:
conn.unbind()
self.dispatch('ldap', '%s [Error DeleteUser] Could not delete %s\n' % (datetime.now(), dn) )
return "Could not delete the user."
conn.unbind()
self.dispatch('ldap', '%s [Info DeleteUser] Deleted %s\n' % (datetime.now(), dn) )
return True
# the class to log all the dispatches
# for now everything gets logged into the same logfile, but
# I don't forsee that much traffic plus with timestamps and the class name
# in the log should be readable
class Log(object):
name = "log"
ldaplog = config['System']['LOGDIR'] + '/ldap.log'
# Gets all the dispatches with 'ldap' and writes them into the ldap.log
@event_handler('userlookup', 'ldap')
@event_handler('createuser', 'ldap')
@event_handler('getuserdata', 'ldap')
@event_handler('changeuserdata', 'ldap')
@event_handler('passwordresetrequest', 'ldap')
@event_handler('changepassword', 'ldap')
@event_handler('deleteuser', 'ldap')
def event_handler_ldap(self, payload):
f = open(self.ldaplog, mode='a', encoding='utf-8')
f.write(payload)
f.close

View file

@ -1,11 +0,0 @@
# Django 6.1 on Python 3.15. Pure Python, no system libraries needed.
django-bootstrap3==26.3
django==6.1.3
djangorestframework==3.18.3
gunicorn==26.2.0
ldap3==2.9.1
psycopg[binary]==3.3.8
pyotp==2.10.0
python-decouple==3.8
requests==2.34.2
whitenoise==6.12.0

22
ungleichuser/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichuser.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,14 @@
django==4.0
ldap3
django-crispy-forms
bootstrap4
#django-auth-ldap
# To check
# django-bootstrap3
# django-filter==2.1.0
# python-decouple
# djangorestframework
# requests

View file

@ -0,0 +1,19 @@
body {
margin: 0;
padding: 0;
background-color: #17a2b8;
height: 100vh;
}
#login .container #login-row #login-column #login-box {
margin-top: 120px;
max-width: 600px;
height: 320px;
border: 1px solid #9C9C9C;
background-color: #EAEAEA;
}
#login .container #login-row #login-column #login-box #login-form {
padding: 20px;
}
#login .container #login-row #login-column #login-box #login-form #register-link {
margin-top: -85px;
}

View file

@ -0,0 +1,3 @@
{% block content %}
Your content here
{% endblock %}

View file

@ -0,0 +1,36 @@
<link href="//maxcdn.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" rel="stylesheet" id="bootstrap-css">
<script src="//maxcdn.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<!------ Include the above in your HEAD tag ---------->
<body>
<div id="login">
<h3 class="text-center text-white pt-5">Login form</h3>
<div class="container">
<div id="login-row" class="row justify-content-center align-items-center">
<div id="login-column" class="col-md-6">
<div id="login-box" class="col-md-12">
<form id="login-form" class="form" action="" method="post">
<h3 class="text-center text-info">Login</h3>
<div class="form-group">
<label for="username" class="text-info">Username:</label><br>
<input type="text" name="username" id="username" class="form-control">
</div>
<div class="form-group">
<label for="password" class="text-info">Password:</label><br>
<input type="text" name="password" id="password" class="form-control">
</div>
<div class="form-group">
<label for="remember-me" class="text-info"><span>Remember me</span> <span><input id="remember-me" name="remember-me" type="checkbox"></span></label><br>
<input type="submit" name="submit" class="btn btn-info btn-md" value="submit">
</div>
<div id="register-link" class="text-right">
<a href="#" class="text-info">Register here</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
<tr>
<td>{{ form.random.label_tag }}</td>
<td>{{ form.random }}</td>
</tr>
</table>
<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
{# Assumes you setup the password_reset view in your URLconf #}
<p><a href="{% url 'password_reset' %}">Lost password?</a></p>
{% endblock %}

View file

@ -0,0 +1,16 @@
"""
ASGI config for ungleichuser project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichuser.settings')
application = get_asgi_application()

View file

@ -0,0 +1,22 @@
import django.contrib.auth.forms as forms
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
# class UngleichAuthenticationForm(forms.AuthenticationForm):
# address = forms.CharField(max_length=45)
class NewUserForm(UserCreationForm):
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ("username", "email", "password1", "password2")
def save(self, commit=True):
user = super(NewUserForm, self).save(commit=False)
user.email = self.cleaned_data['email']
if commit:
user.save()
return user

View file

@ -0,0 +1,128 @@
"""
Django settings for ungleichuser project.
Generated by 'django-admin startproject' using Django 4.0rc1.
For more information on this file, see
https://docs.djangoproject.com/en/dev/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/dev/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-(9@_07zxslciu-%wnc7f$qx%=8esu!fuv(72m!6r4f(&#u8$9s'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'crispy_forms',
'ungleichuser'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'ungleichuser.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'ungleichuser.wsgi.application'
# Database
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/dev/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/dev/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CRISPY_TEMPLATE_PACK = 'bootstrap4'

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Title</title>
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>

View file

@ -0,0 +1,18 @@
{% extends "ungleichuser/header.html" %}
{% block content %}
{% load crispy_forms_tags %}
<!--Register-->
<div class="container py-5">
<h1>Register</h1>
<form method="POST">
{% csrf_token %}
{{ register_form|crispy }}
<button class="btn btn-primary" type="submit">Register</button>
</form>
<p class="text-center">If you already have an account, <a href="/login">login</a> instead.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
"""ungleichuser URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/dev/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
import django.contrib.auth
from django.urls import path, include
from . import views
urlpatterns = [
# path('admin/', admin.site.urls),
# path("", views.homepage, name="homepage"),
path("register", views.register_request, name="register"),
path('accounts/', include('django.contrib.auth.urls')),
]

View file

@ -0,0 +1,9 @@
import ipaddress
import random
v4_addr = ipaddress.IPv4Address(random.randint(0, 2**32))
v6_addr = ipaddress.IPv6Address(random.randint(0, 2**128))
if __name__ == '__main__':
addr_type = random.choice(["ipv6", "ipv4"])
if addr_type == "ipv6":

View file

@ -0,0 +1,21 @@
from django.shortcuts import render, redirect
from .forms import NewUserForm
from django.contrib.auth import login
from django.contrib import messages
def register_request(request):
if request.method == "POST":
form = NewUserForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
messages.success(request, "Registration successful." )
return redirect("main:homepage")
messages.error(request, "Unsuccessful registration. Invalid information.")
form = NewUserForm()
return render (request=request,
template_name="ungleichuser/register.html",
context={"register_form":form})

View file

@ -0,0 +1,16 @@
"""
WSGI config for ungleichuser project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ungleichuser.settings')
application = get_wsgi_application()