forked from ungleich-public/ungleich-user
Compare commits
No commits in common. "master" and "master" have entirely different histories.
89 changed files with 1237 additions and 2791 deletions
|
|
@ -1,14 +0,0 @@
|
|||
.git
|
||||
.venv
|
||||
venv
|
||||
.env
|
||||
*.sqlite3
|
||||
static/
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
dal/tests
|
||||
dev
|
||||
deploy
|
||||
docker-compose*.yml
|
||||
compose.yaml
|
||||
*.md
|
||||
100
.env.example
100
.env.example
|
|
@ -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
4
.gitignore
vendored
|
|
@ -39,6 +39,4 @@ secret-key
|
|||
*.mo
|
||||
|
||||
venv/
|
||||
|
||||
# dev LDAP CA certificate, regenerated on every start
|
||||
dev/ldap/export/
|
||||
dal/ldap_max_uid_file
|
||||
|
|
|
|||
58
Dockerfile
58
Dockerfile
|
|
@ -1,50 +1,16 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Production image for the ungleich account webapp.
|
||||
# All configuration comes from environment variables: see docker.md and
|
||||
# .env.example.
|
||||
#
|
||||
# docker build -t ungleich-user .
|
||||
# docker run --env-file .env -p 8000:8000 ungleich-user
|
||||
#
|
||||
# Database migrations are not run on start; run them once per release:
|
||||
# docker run --env-file .env ungleich-user python manage.py migrate_locked
|
||||
FROM python:3.10.0-alpine3.15
|
||||
|
||||
FROM python:3.15-slim-trixie
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
RUN apk add --update --no-cache\
|
||||
build-base \
|
||||
openldap-dev\
|
||||
python3-dev\
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Security updates for the base image. No sshd or compiler is installed:
|
||||
# every Python dependency ships as a pure Python or binary wheel.
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --no-create-home --home-dir /app app \
|
||||
&& mkdir /data && chown 10001 /data
|
||||
# FIX https://github.com/python-ldap/python-ldap/issues/432
|
||||
RUN echo 'INPUT ( libldap.so )' > /usr/lib/libldap_r.so
|
||||
|
||||
# Only used without DATABASE_HOST: SQLite lives on a volume mounted at
|
||||
# /data (owned by the app user, so a fresh named volume is writable).
|
||||
ENV SQLITE_PATH=/data/db.sqlite3
|
||||
VOLUME /data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY manage.py gunicorn.conf.py ./
|
||||
COPY dal ./dal
|
||||
|
||||
# Settings need the required variables to load; these placeholders exist
|
||||
# only for this one command and are not part of the image's environment.
|
||||
RUN SECRET_KEY=build LDAPSERVER=ldap://build LDAP_ADMIN_DN=build \
|
||||
LDAP_ADMIN_PASSWORD=build EMAIL_FROM_ADDRESS=build@example.com \
|
||||
python manage.py collectstatic --noinput -v0
|
||||
|
||||
USER 10001
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "dal.wsgi:application"]
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY ungleichuser/ .
|
||||
|
|
|
|||
118
README.md
118
README.md
|
|
@ -1,67 +1,83 @@
|
|||
# ungleich account webapp
|
||||
# Ungleich Account Management Webapp
|
||||
|
||||
This service runs on [account.ungleich.ch](https://account.ungleich.ch/).
|
||||
Customers use it to manage their ungleich LDAP account: register (with
|
||||
email confirmation), log in, change their name, email or password, reset a
|
||||
forgotten password, delete the account, and view their OTP seeds. It also
|
||||
offers a small API for creating accounts and fetching OTP seeds.
|
||||
This service runs on [account.ungleich.ch](https://account.ungleich.ch/) and
|
||||
allows customers manage their ungleich account (register, edit mail & password,
|
||||
...).
|
||||
|
||||
Django 6.1 on Python 3.15. LDAP access goes through
|
||||
[ldap3](https://ldap3.readthedocs.io/) only, with no C extensions.
|
||||
## Status v2
|
||||
|
||||
## Development
|
||||
* See below ungleichuser/
|
||||
* register view created
|
||||
* Not solved:
|
||||
* email verification *before* user is created
|
||||
* Solved
|
||||
* Finding highest uid, increasing by 1
|
||||
|
||||
You need Docker and [uv](https://docs.astral.sh/uv/) (or any Python 3.15).
|
||||
Only LDAP (and optionally PostgreSQL) runs in containers. The app runs
|
||||
natively.
|
||||
## Todos for v2:
|
||||
|
||||
* Rewrite/create new app Django 4.0 based (ungleichuser)
|
||||
* Do not use django-auth-ldap, because it uses python-ldap
|
||||
* Update the template HTML to bootstrap5
|
||||
* Use ldap3
|
||||
* Check/verify the ldap registration
|
||||
* Ensure multiple ldap servers are supported
|
||||
* Cleanup this readme
|
||||
* Use the following "captcha" for all forms:
|
||||
* Extra form field named "random"
|
||||
* Present the user the following text:
|
||||
* Create Dockerfile / ensure it works in kubernetes
|
||||
|
||||
## Next steps
|
||||
|
||||
* Override/use custom form to allow captcha
|
||||
|
||||
## Development Setup
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.dev.yml up -d --build # OpenLDAP 2.6 on :3389, PostgreSQL on :5433
|
||||
cp dev/env.dev .env # dev-only settings
|
||||
uv venv -p 3.15 .venv && uv pip install -r requirements.txt
|
||||
.venv/bin/python manage.py migrate
|
||||
.venv/bin/python manage.py runserver
|
||||
```
|
||||
python3 -m venv venv
|
||||
. ./venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
``
|
||||
|
||||
The test LDAP mirrors the production layout (`dc=ungleich,dc=ch`,
|
||||
`ou=customer`, `ou=users`) and comes with `customer1` / `customerpass`
|
||||
and `staff1` / `staffpass`. To reset its data:
|
||||
`docker compose -f docker-compose.dev.yml up -d --force-recreate ldap`.
|
||||
## Functionality
|
||||
|
||||
### Tests
|
||||
x Allow user to register in a specific subtree
|
||||
x Verify that user does not exist in another subtree
|
||||
x Assign an id
|
||||
- Allow password reset [via Mail?]
|
||||
- DB usage: for password reset?
|
||||
|
||||
```sh
|
||||
.venv/bin/python manage.py test dal
|
||||
```
|
||||
## Parameters
|
||||
|
||||
The tests in `dal/tests/` exercise every view over HTTP against the real
|
||||
test LDAP server, which must be running. Mail and the OTP server are
|
||||
mocked. Set `DATABASE_HOST=localhost DATABASE_PORT=5433
|
||||
DATABASE_PASSWORD=dev-only` to run them on PostgreSQL instead of SQLite.
|
||||
Via environment variables:
|
||||
|
||||
## Configuration
|
||||
* LDAPSERVERS=".." -- White space separated list of LDAP-Servers
|
||||
* ADMIN_DN="" -- we use this DN to connect to LDAP
|
||||
* ADMIN_PASSWORD="" -- we use this password to connect to LDAP
|
||||
* SECRET_KEY
|
||||
* DEBUG
|
||||
* ALLOWED_HOSTS
|
||||
|
||||
Every setting is an environment variable (a `.env` file in development).
|
||||
[`.env.example`](.env.example) lists each one, with its default and whether it
|
||||
is a secret.
|
||||
|
||||
## Deployment
|
||||
## Views
|
||||
|
||||
The [`Dockerfile`](Dockerfile) builds a production image: gunicorn running
|
||||
as non-root, static files served by WhiteNoise, and logs on stdout.
|
||||
[`docker.md`](docker.md) explains how to deploy it with `docker run`,
|
||||
Docker Compose ([`compose.yaml`](compose.yaml)) or Helm, and which
|
||||
variables are needed. For the plain Kubernetes manifests, and testing
|
||||
locally with kind, see [`deploy/k8s/README.md`](deploy/k8s/README.md).
|
||||
### Register view
|
||||
|
||||
## Layout
|
||||
Form: [get]
|
||||
|
||||
| Path | Contents |
|
||||
| --- | --- |
|
||||
| `dal/views.py` | All views |
|
||||
| `dal/ungleich_ldap.py` | LDAP writes and searches (`LdapManager`, server pool) |
|
||||
| `dal/backends.py` | LDAP login backend |
|
||||
| `dal/health.py` | `/healthz` and `/readyz` |
|
||||
| `dal/tests/` | End-to-end tests |
|
||||
| `dev/` | Dev settings and the test OpenLDAP image |
|
||||
| `deploy/k8s/` | Kubernetes manifests (base and kind dev overlay) |
|
||||
* username
|
||||
* password1
|
||||
* password2
|
||||
|
||||
Post receiver: [post]
|
||||
|
||||
* E-Mail verification
|
||||
|
||||
E-Mail verify: [get]
|
||||
|
||||
* Create user
|
||||
|
||||
### Password reset view
|
||||
|
||||
* captcha
|
||||
* username or email
|
||||
|
|
|
|||
61
compose.yaml
61
compose.yaml
|
|
@ -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:
|
||||
|
|
@ -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
37
dal/env.sample
Normal 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=''
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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])
|
||||
|
|
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
212
dal/settings.py
212
dal/settings.py
|
|
@ -1,49 +1,48 @@
|
|||
"""
|
||||
Django settings for the ungleich account webapp (dal).
|
||||
Django settings for dal project.
|
||||
|
||||
All deployment-specific values come from environment variables (or a
|
||||
.env file in development), read here and nowhere else. See .env.example
|
||||
and docker.md for the full list, which ones are required, and which are
|
||||
secrets.
|
||||
Generated by 'django-admin startproject' using Django 1.10.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/1.10/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/1.10/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from decouple import config, Csv
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
|
||||
|
||||
# LDAP setup
|
||||
# Space separated list of LDAP URIs; the first reachable one is used.
|
||||
LDAP_SERVERS = config('LDAPSERVER').split()
|
||||
LDAP_SERVER = config('LDAP_SERVER')
|
||||
AUTH_LDAP_SERVER_URI = config('LDAPSERVER')
|
||||
|
||||
LDAP_ADMIN_DN = config('LDAP_ADMIN_DN')
|
||||
LDAP_ADMIN_PASSWORD = config('LDAP_ADMIN_PASSWORD')
|
||||
AUTH_LDAP_BIND_DN = LDAP_ADMIN_DN
|
||||
AUTH_LDAP_BIND_PASSWORD = LDAP_ADMIN_PASSWORD
|
||||
AUTH_LDAP_SERVER = AUTH_LDAP_SERVER_URI
|
||||
|
||||
# The directory layout defaults to production's: customers in ou=customer,
|
||||
# staff in ou=users, both below one base. Another tree (e.g. a test
|
||||
# server) usually only needs ENTIRE_SEARCH_BASE.
|
||||
# Usernames and uidNumbers are unique below ENTIRE_SEARCH_BASE.
|
||||
ENTIRE_SEARCH_BASE = config('ENTIRE_SEARCH_BASE', default='dc=ungleich,dc=ch')
|
||||
# New registrations are created here.
|
||||
LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN', default='ou=customer,' + ENTIRE_SEARCH_BASE)
|
||||
LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', default=10004, cast=int)
|
||||
LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN')
|
||||
LDAP_USERS_DN = config('LDAP_USERS_DN')
|
||||
LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', cast=int)
|
||||
|
||||
LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', default=10000, cast=int)
|
||||
# objectClasses of new customer entries (space separated). ldapPublicKey
|
||||
# needs the openssh-lpk schema, which production has.
|
||||
LDAP_USER_OBJECT_CLASSES = config(
|
||||
'LDAP_USER_OBJECT_CLASSES', default='inetOrgPerson posixAccount ldapPublicKey').split()
|
||||
LDAP_MAX_UID_FILE_PATH = config(
|
||||
'LDAP_MAX_UID_FILE_PATH',
|
||||
default=os.path.join(os.path.abspath(
|
||||
os.path.dirname(__file__)), 'ldap_max_uid_file'
|
||||
)
|
||||
)
|
||||
LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', cast=int)
|
||||
|
||||
# Users log in from any of these (space separated) search bases.
|
||||
LDAP_SEARCH_BASES = config(
|
||||
'LDAPSEARCH',
|
||||
default='ou=customer,{0} ou=users,{0}'.format(ENTIRE_SEARCH_BASE)).split()
|
||||
# Search union over OUs
|
||||
search_base = config('LDAPSEARCH').split()
|
||||
search_base_ldap = [ LDAPSearch(x, ldap.SCOPE_SUBTREE, "(uid=%(user)s)") for x in search_base ]
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*search_base_ldap)
|
||||
|
||||
# StartTLS on every connection (use ldaps:// URIs for implicit TLS).
|
||||
LDAP_USE_TLS = config('LDAP_USE_TLS', default=False, cast=bool)
|
||||
# Verify LDAP server certificates (ldaps:// and StartTLS). LDAP_CA_FILE
|
||||
# adds a CA bundle for servers with a private CA; the system store is used
|
||||
# otherwise. Only disable verification for local experiments.
|
||||
LDAP_TLS_VERIFY = config('LDAP_TLS_VERIFY', default=True, cast=bool)
|
||||
LDAP_CA_FILE = config('LDAP_CA_FILE', default='')
|
||||
AUTH_LDAP_START_TLS = config('LDAP_USE_TLS', default=False, cast=bool)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
|
@ -51,39 +50,11 @@ DEBUG = config('DEBUG', default=False, cast=bool)
|
|||
|
||||
EMAIL_FROM_ADDRESS = config('EMAIL_FROM_ADDRESS')
|
||||
|
||||
MAILERS = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
|
||||
"OPTIONS": {
|
||||
"host": config("EMAIL_HOST", default="localhost"),
|
||||
"port": config("EMAIL_PORT", default=25, cast=int),
|
||||
"username": config("EMAIL_HOST_USER", default=""),
|
||||
"password": config("EMAIL_HOST_PASSWORD", default=""),
|
||||
"use_tls": config("EMAIL_USE_TLS", default=True, cast=bool),
|
||||
"timeout": config("EMAIL_TIMEOUT", default=10, cast=int),
|
||||
},
|
||||
},
|
||||
}
|
||||
EMAIL_HOST = config("EMAIL_HOST", default="localhost")
|
||||
EMAIL_PORT = config("EMAIL_PORT", default=25, cast=int)
|
||||
EMAIL_USE_TLS = config("EMAIL_USE_TLS", default=True, cast=bool)
|
||||
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='account.ungleich.ch', cast=Csv())
|
||||
|
||||
# --- HTTPS / proxy -----------------------------------------------------------
|
||||
# Behind a TLS-terminating proxy (e.g. a Kubernetes ingress) Django sees plain
|
||||
# HTTP. Only enable this if the proxy always sets X-Forwarded-Proto.
|
||||
if config('TRUST_X_FORWARDED_PROTO', default=False, cast=bool):
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# Full origins (scheme included). Defaults to https:// plus each
|
||||
# ALLOWED_HOSTS entry, which is what a site behind an HTTPS ingress needs.
|
||||
CSRF_TRUSTED_ORIGINS = config(
|
||||
'CSRF_TRUSTED_ORIGINS',
|
||||
default=','.join('https://' + h.lstrip('.') for h in ALLOWED_HOSTS if h != '*'),
|
||||
cast=Csv())
|
||||
|
||||
SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=not DEBUG, cast=bool)
|
||||
CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=not DEBUG, cast=bool)
|
||||
SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=False, cast=bool)
|
||||
SECURE_HSTS_SECONDS = config('SECURE_HSTS_SECONDS', default=0, cast=int)
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
|
|
@ -98,10 +69,7 @@ INSTALLED_APPS = [
|
|||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
# First: answers /healthz and /readyz before host checks and redirects.
|
||||
'dal.health.HealthCheckMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
|
@ -111,7 +79,7 @@ MIDDLEWARE = [
|
|||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'dal.backends.LdapBackend',
|
||||
'django_auth_ldap.backend.LDAPBackend',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -163,6 +131,8 @@ TIME_ZONE = 'UTC'
|
|||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
|
|
@ -170,106 +140,76 @@ USE_TZ = True
|
|||
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
# collectstatic target; served by WhiteNoise (gzip compressed).
|
||||
# Not the hashed "Manifest" storage: the bundled vendor CSS references
|
||||
# source maps that are not shipped, which makes hashing fail.
|
||||
STATIC_ROOT = config('STATIC_ROOT', default=os.path.join(BASE_DIR, 'static'))
|
||||
|
||||
STORAGES = {
|
||||
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
|
||||
'staticfiles': {
|
||||
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage',
|
||||
},
|
||||
}
|
||||
############################# To be fixed
|
||||
|
||||
STATIC_ROOT= os.path.join(BASE_DIR, 'static/')
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||
|
||||
# PostgreSQL when DATABASE_HOST is set (needed for more than one replica),
|
||||
# otherwise SQLite at SQLITE_PATH.
|
||||
if config('DATABASE_HOST', default=''):
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'HOST': config('DATABASE_HOST'),
|
||||
'PORT': config('DATABASE_PORT', default=5432, cast=int),
|
||||
'NAME': config('DATABASE_NAME', default='ungleich_user'),
|
||||
'USER': config('DATABASE_USER', default='ungleich_user'),
|
||||
'PASSWORD': config('DATABASE_PASSWORD', default=''),
|
||||
'CONN_MAX_AGE': config('DATABASE_CONN_MAX_AGE', default=60, cast=int),
|
||||
'CONN_HEALTH_CHECKS': True,
|
||||
'OPTIONS': {
|
||||
'sslmode': config('DATABASE_SSLMODE', default='prefer'),
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': config('SQLITE_PATH', default=os.path.join(BASE_DIR, 'db.sqlite3')),
|
||||
}
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
||||
}
|
||||
}
|
||||
SECRET_KEY = config('SECRET_KEY')
|
||||
|
||||
# Keep 32-bit ids: existing tables were created with AutoField.
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
"email": "mail"
|
||||
}
|
||||
|
||||
# Everything goes to stdout/stderr (12-factor / Kubernetes friendly).
|
||||
LOG_LEVEL = config('LOG_LEVEL', default='INFO').upper()
|
||||
ENTIRE_SEARCH_BASE = config("ENTIRE_SEARCH_BASE")
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'version': 1,
|
||||
'formatters': {
|
||||
'standard': {
|
||||
'format': '%(asctime)s %(levelname)s %(name)s %(funcName)s %(lineno)d: %(message)s'
|
||||
}
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'default': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/debug.log',
|
||||
'maxBytes': 1024*1024*5,
|
||||
'backupCount': 10,
|
||||
'formatter': 'standard',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
},
|
||||
'loggers': {
|
||||
# Keep Django's own request/DB chatter at INFO unless asked for.
|
||||
'django': {
|
||||
'level': config('DJANGO_LOG_LEVEL', default='INFO').upper(),
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ldap3 protocol logging is very verbose; opt in with LDAP3_LOG=1.
|
||||
if config('LDAP3_LOG', default=False, cast=bool):
|
||||
from ldap3.utils.log import set_library_log_detail_level, BASIC
|
||||
set_library_log_detail_level(BASIC)
|
||||
if config('ENABLE_DEBUG_LOG', default=False, cast=bool):
|
||||
loggers_dict = {}
|
||||
modules_to_log_list = config(
|
||||
'MODULES_TO_LOG', default='django', cast=Csv()
|
||||
)
|
||||
for custom_module in modules_to_log_list:
|
||||
logger_item = {
|
||||
custom_module: {
|
||||
'handlers': ['default'],
|
||||
'level': 'INFO',
|
||||
'propagate': True
|
||||
}
|
||||
}
|
||||
loggers_dict.update(logger_item)
|
||||
|
||||
LOGGING['loggers'] = loggers_dict
|
||||
|
||||
if 'ldap3' in modules_to_log_list:
|
||||
from ldap3.utils.log import (
|
||||
set_library_log_detail_level, OFF, BASIC, NETWORK, EXTENDED
|
||||
)
|
||||
set_library_log_detail_level(BASIC)
|
||||
|
||||
# OTP server that stores per-user TOTP seeds, accessed with an admin TOTP.
|
||||
# Optional: without OTPSERVER, new accounts get no OTP seed and the seed
|
||||
# pages report that OTP is not configured.
|
||||
OTP_SERVER = config('OTPSERVER', default='')
|
||||
OTP_ADMIN_SEED = config('ADMIN_SEED', default='')
|
||||
OTP_ADMIN_NAME = config('ADMIN_NAME', default='')
|
||||
OTP_ADMIN_REALM = config('ADMIN_REALM', default='')
|
||||
OTP_USER_REALM = config('USER_REALM', default='')
|
||||
OTP_ALLOWED_REALMS = config('ALLOWED_REALMS', default='', cast=Csv())
|
||||
if OTP_SERVER and not all([OTP_ADMIN_SEED, OTP_ADMIN_NAME, OTP_ADMIN_REALM, OTP_USER_REALM]):
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
raise ImproperlyConfigured(
|
||||
'OTPSERVER is set, so ADMIN_SEED, ADMIN_NAME, ADMIN_REALM and USER_REALM are required too')
|
||||
OTP_TIMEOUT = config('OTP_TIMEOUT', default=10, cast=int) # seconds
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static i18n %} <!--cms_tags %}-->
|
||||
{% load staticfiles i18n %} <!--cms_tags %}-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Success! </title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Password Updated. </title>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Verify your email. </title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Deleting Account </title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Deleted user {{user}} </title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title>An error has occurred!</title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "hosting/base_short.html" %}
|
||||
{% load i18n static %}
|
||||
{% load i18n staticfiles %}
|
||||
|
||||
{% block navbar %}
|
||||
{% include 'hosting/includes/_navbar_transparent.html' %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<a name="about"></a>
|
||||
<div class="intro-header">
|
||||
<div class="container">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
|
||||
|
||||
<nav class="navbar navbar-default navbar-fixed-top topnav" role="navigation">
|
||||
|
|
|
|||
49
dal/templates/includes/_navbar_user.html
Normal file
49
dal/templates/includes/_navbar_user.html
Normal 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> {% 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> {{request.user.name}} <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> Deutsch</a>
|
||||
{% else %}
|
||||
<a href="{% change_lang 'en-us' %}"><i class="fa fa-fw fa-globe" aria-hidden="true"></i> English</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- /.navbar-collapse -->
|
||||
</div>
|
||||
<!-- /.container -->
|
||||
</nav>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
|
||||
<a name="our"></a>
|
||||
<div class="content-section-b">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<a name="price"></a>
|
||||
<div class="content-section-b">
|
||||
<div class="container-fluid pricing-container">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
|
||||
<a name="your"></a>
|
||||
<div class="content-section-a" id="own">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title>ungleich</title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title>Login failed!</title>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> Password reset </title>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title>Options for {{user}}</title>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title> User {{ user }} created. </title>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base_short.html" %}
|
||||
{% load i18n static bootstrap3 %}
|
||||
{% load i18n staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}
|
||||
<title>Options for {{user}}</title>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
257
dal/views.py
257
dal/views.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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: ""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
minAvailable: 1
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ungleich-user-dev
|
||||
|
|
@ -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}
|
||||
|
|
@ -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: ""
|
||||
32
dev/env.dev
32
dev/env.dev
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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 ) )
|
||||
|
|
@ -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
|
||||
|
|
@ -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
227
docker.md
|
|
@ -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.
|
||||
|
|
@ -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
350
nameko-func.py
Normal 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
|
||||
|
||||
|
|
@ -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
22
ungleichuser/manage.py
Executable 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()
|
||||
14
ungleichuser/requirements.txt
Normal file
14
ungleichuser/requirements.txt
Normal 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
|
||||
19
ungleichuser/static/base.css
Normal file
19
ungleichuser/static/base.css
Normal 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;
|
||||
}
|
||||
3
ungleichuser/templates/base.html
Normal file
3
ungleichuser/templates/base.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% block content %}
|
||||
Your content here
|
||||
{% endblock %}
|
||||
36
ungleichuser/templates/login.html
Normal file
36
ungleichuser/templates/login.html
Normal 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>
|
||||
42
ungleichuser/templates/registration/login.html
Normal file
42
ungleichuser/templates/registration/login.html
Normal 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 %}
|
||||
16
ungleichuser/ungleichuser/asgi.py
Normal file
16
ungleichuser/ungleichuser/asgi.py
Normal 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()
|
||||
22
ungleichuser/ungleichuser/forms.py
Normal file
22
ungleichuser/ungleichuser/forms.py
Normal 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
|
||||
128
ungleichuser/ungleichuser/settings.py
Normal file
128
ungleichuser/ungleichuser/settings.py
Normal 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'
|
||||
16
ungleichuser/ungleichuser/templates/ungleichuser/header.html
Normal file
16
ungleichuser/ungleichuser/templates/ungleichuser/header.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
26
ungleichuser/ungleichuser/urls.py
Normal file
26
ungleichuser/ungleichuser/urls.py
Normal 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')),
|
||||
]
|
||||
9
ungleichuser/ungleichuser/v6v4.py
Normal file
9
ungleichuser/ungleichuser/v6v4.py
Normal 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":
|
||||
21
ungleichuser/ungleichuser/views.py
Normal file
21
ungleichuser/ungleichuser/views.py
Normal 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})
|
||||
16
ungleichuser/ungleichuser/wsgi.py
Normal file
16
ungleichuser/ungleichuser/wsgi.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue