From 692c1b16574912d9c3d556aafddec49851a70de5 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 14:46:24 +0300 Subject: [PATCH 01/51] Add local OpenLDAP 2.6 test server for development Alpine 3.24 based slapd with the production directory layout (dc=ungleich,dc=ch, ou=customers, ou=users) and the openssh-lpk schema. The app runs natively against it on ldap://localhost:3389. Includes dev/env.dev with dev-only settings (cp dev/env.dev .env). --- dev/env.dev | 30 ++++++++++++++++++++ dev/ldap/Dockerfile | 23 +++++++++++++++ dev/ldap/config.ldif | 53 +++++++++++++++++++++++++++++++++++ dev/ldap/entrypoint.sh | 24 ++++++++++++++++ dev/ldap/openssh-lpk.ldif | 13 +++++++++ dev/ldap/seed.ldif | 59 +++++++++++++++++++++++++++++++++++++++ docker-compose.dev.yml | 13 +++++++++ 7 files changed, 215 insertions(+) create mode 100644 dev/env.dev create mode 100644 dev/ldap/Dockerfile create mode 100644 dev/ldap/config.ldif create mode 100644 dev/ldap/entrypoint.sh create mode 100644 dev/ldap/openssh-lpk.ldif create mode 100644 dev/ldap/seed.ldif create mode 100644 docker-compose.dev.yml diff --git a/dev/env.dev b/dev/env.dev new file mode 100644 index 0000000..9a2b99a --- /dev/null +++ b/dev/env.dev @@ -0,0 +1,30 @@ +# 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_SERVER="ldap://localhost:3389" +LDAP_ADMIN_DN="cn=admin,dc=ungleich,dc=ch" +LDAP_ADMIN_PASSWORD="admin" + +LDAP_CUSTOMER_DN="ou=customers,dc=ungleich,dc=ch" +LDAP_USERS_DN="ou=users,dc=ungleich,dc=ch" +LDAP_CUSTOMER_GROUP_ID=2000 +LDAP_DEFAULT_START_UID=10000 + +# Space separated list of search bases for users +LDAPSEARCH="ou=customers,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 +ENABLE_DEBUG_LOG=False +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" diff --git a/dev/ldap/Dockerfile b/dev/ldap/Dockerfile new file mode 100644 index 0000000..d7bd11e --- /dev/null +++ b/dev/ldap/Dockerfile @@ -0,0 +1,23 @@ +# 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 \ + && 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 +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"] diff --git a/dev/ldap/config.ldif b/dev/ldap/config.ldif new file mode 100644 index 0000000..12d2c00 --- /dev/null +++ b/dev/ldap/config.ldif @@ -0,0 +1,53 @@ +dn: cn=config +objectClass: olcGlobal +cn: config +olcArgsFile: /run/openldap/slapd.args +olcPidFile: /run/openldap/slapd.pid + +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 diff --git a/dev/ldap/entrypoint.sh b/dev/ldap/entrypoint.sh new file mode 100644 index 0000000..327a2a9 --- /dev/null +++ b/dev/ldap/entrypoint.sh @@ -0,0 +1,24 @@ +#!/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:?}"/* + +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 + +# -d keeps slapd in the foreground; "stats" logs every operation to stdout. +exec slapd -u ldap -g ldap -F "$CONF" -h "ldap:///" -d "${SLAPD_LOGLEVEL:-stats}" diff --git a/dev/ldap/openssh-lpk.ldif b/dev/ldap/openssh-lpk.ldif new file mode 100644 index 0000000..3a8586e --- /dev/null +++ b/dev/ldap/openssh-lpk.ldif @@ -0,0 +1,13 @@ +# 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 ) ) diff --git a/dev/ldap/seed.ldif b/dev/ldap/seed.ldif new file mode 100644 index 0000000..3a34471 --- /dev/null +++ b/dev/ldap/seed.ldif @@ -0,0 +1,59 @@ +# Mirrors the production layout: dc=ungleich,dc=ch with ou=customers and +# ou=users. 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=customers,dc=ungleich,dc=ch +objectClass: organizationalUnit +ou: customers + +dn: ou=users,dc=ungleich,dc=ch +objectClass: organizationalUnit +ou: users + +dn: ou=groups,dc=ungleich,dc=ch +objectClass: organizationalUnit +ou: groups + +dn: cn=customers,ou=groups,dc=ungleich,dc=ch +objectClass: posixGroup +cn: customers +gidNumber: 2000 + +dn: uid=staff1,ou=users,dc=ungleich,dc=ch +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: ldapPublicKey +uid: staff1 +cn: staff1 +sn: Staff +givenName: Sam +displayName: Sam Staff +mail: staff1@example.com +uidNumber: 10000 +gidNumber: 2000 +homeDirectory: /home/staff1 +loginShell: /bin/bash +userPassword: staffpass + +dn: uid=customer1,ou=customers,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: 2000 +homeDirectory: /home/customer1 +loginShell: /bin/bash +userPassword: customerpass diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..7f51842 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,13 @@ +# Dev dependencies only. The Django app runs natively (no container) and +# talks to this on ldap://localhost:3389. +# +# 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" From 13b1d9c23a431b30842e3913f29cd90fdb7eab74 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 14:46:24 +0300 Subject: [PATCH 02/51] Add end-to-end tests and pin production baseline dependencies Restore root requirements.txt pinned to what production runs (Django 2.2, last patch 2.2.28) so the upgrade starts from a reproducible baseline. Restore logs/.keep; the logging config fails on a fresh clone without the directory. The tests drive every view over HTTP against the real test LDAP: login, registration and activation, data and password changes, password reset, account deletion, the create API and OTP seeds. Known bugs are recorded as expected failures: - password reset POST changes any user's password without a token - LDAP filter injection in reset (user='*' hits the first entry) - activation link carries the base64-encoded password - a username from ou=users can be registered again under ou=customers --- dal/tests/__init__.py | 0 dal/tests/base.py | 134 ++++++++++++++++ dal/tests/test_flows.py | 347 ++++++++++++++++++++++++++++++++++++++++ logs/.keep | 0 requirements.txt | 11 ++ 5 files changed, 492 insertions(+) create mode 100644 dal/tests/__init__.py create mode 100644 dal/tests/base.py create mode 100644 dal/tests/test_flows.py create mode 100644 logs/.keep create mode 100644 requirements.txt diff --git a/dal/tests/__init__.py b/dal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dal/tests/base.py b/dal/tests/base.py new file mode 100644 index 0000000..3939495 --- /dev/null +++ b/dal/tests/base.py @@ -0,0 +1,134 @@ +""" +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 os +import re +import tempfile +import uuid +from unittest import mock + +import ldap3 +from django.conf import settings +from django.core import mail +from django.test import TestCase, override_settings + +from dal.ungleich_ldap import LdapManager + +_ldap_checked = False + + +def admin_conn(): + server = ldap3.Server(settings.AUTH_LDAP_SERVER) + 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( + settings.AUTH_LDAP_SERVER, exc) + ) + _ldap_checked = True + + +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 = [] + fd, self.uid_file = tempfile.mkstemp(prefix="ldap_max_uid_") + os.close(fd) + os.unlink(self.uid_file) # app must cope with a missing file + self._settings = override_settings(LDAP_MAX_UID_FILE_PATH=self.uid_file) + self._settings.enable() + # 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() + self._settings.disable() + if os.path.exists(self.uid_file): + os.unlink(self.uid_file) + + # LDAP helpers --------------------------------------------------------- + + def make_ldap_user(self, uid=None, password="initial-pass-123", + ou="customers", email=None): + """Create a user directly in LDAP, the same way the app does.""" + uid = uid or unique_uid() + dn = "uid={},ou={},dc=ungleich,dc=ch".format(uid, ou) + conn = admin_conn() + ok = conn.add(dn, ["inetOrgPerson", "posixAccount", "ldapPublicKey"], { + "uid": uid, "cn": uid, "sn": "Tester", "givenName": "Tina", + "mail": email or "{}@example.com".format(uid), + "uidNumber": str(30000 + int(uuid.uuid4().int % 100000)), + "gidNumber": "2000", "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="customers"): + """Register a user created by the app for cleanup.""" + self._created_dns.append("uid={},ou={},dc=ungleich,dc=ch".format(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="customers"): + server = ldap3.Server(settings.AUTH_LDAP_SERVER) + conn = ldap3.Connection( + server, "uid={},ou={},dc=ungleich,dc=ch".format(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] diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py new file mode 100644 index 0000000..88d4aef --- /dev/null +++ b/dal/tests/test_flows.py @@ -0,0 +1,347 @@ +""" +End-to-end behaviour of the account webapp against a real LDAP server. + +Tests decorated with @expectedFailure document known bugs found during the +upgrade audit. When a bug is fixed the test starts passing, Django reports an +"unexpected success", and the decorator must be removed. +""" +from unittest import expectedFailure + +from django.core import mail + +from .base import 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_logged_in_user_gets_options_page(self): + uid = self.make_ldap_user(password="cust-pass-123") + self.login(uid, "cust-pass-123") + self.assertTemplateUsed(self.client.get("/"), "useroptions.html") + + 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(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), 2000) + self.assertIn("ldapPublicKey", entry.objectClass.values) + 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_tampered_activation_token_is_rejected(self): + data = self.form() + self.track(data["username"]) + self.client.post(REGISTER, data) + link = self.last_mail_link().rstrip("/") + bad = link.rsplit("/", 1)[0] + "/not-the-token/" + self.assertEqual(self.client.get(bad).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]) + + @expectedFailure + def test_bug_password_not_in_activation_link(self): + """SECURITY: link carries base64(password); it lands in mail/proxy logs.""" + 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) + + @expectedFailure + def test_bug_cannot_register_name_taken_by_staff(self): + """Username in ou=users must not be re-registrable under ou=customers.""" + 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, "ou=customers,dc=ungleich,dc=ch")) + + 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_rejects_short_new_password(self): + uid = self.make_ldap_user(password="old-pass-1234") + resp = self.client.post("/reset/", { + "user": uid, "password1": "short", "password2": "short"}) + self.assertContains(resp, "too short") + + @expectedFailure + def test_bug_reset_post_requires_a_valid_token(self): + """CRITICAL: POST /reset/ sets any user's password without a token.""" + 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") + + @expectedFailure + def test_bug_ldap_filter_injection_in_reset(self): + """CRITICAL: user='*' matches 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("dc=ungleich,dc=ch", "(uid=*)", attributes=["uid"]) + uids = [e.uid.value for e in conn.entries] + conn.unbind() + hijacked = [u for u in uids for ou in ("customers", "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) + 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_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) diff --git a/logs/.keep b/logs/.keep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..986f294 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Baseline: what production runs (Django 2.2), pinned so the upgrade is reproducible. +django==2.2.28 +django-auth-ldap==2.2.0 +python-ldap==3.4.4 +django-bootstrap3==14.2.0 +django-filter==2.1.0 +python-decouple==3.8 +ldap3==2.9.1 +djangorestframework==3.12.4 +pyotp==2.9.0 +requests==2.32.3 From b44e2ffcf43f55c0d3a84cbefb590e399c602a49 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:01:06 +0300 Subject: [PATCH 03/51] Escape user input in LDAP filters and DNs The uid was formatted straight into search filters, so a password reset for user '*' matched and changed the first entry in the tree (staff accounts included). Escape filter values with escape_filter_chars and DN values with escape_rdn. Also fix the username pattern: [a-z|0-9|\-|_] allowed '|'. --- dal/tests/test_flows.py | 6 +++--- dal/ungleich_ldap.py | 8 +++++--- dal/views.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index 88d4aef..a440320 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -73,6 +73,7 @@ class RegisterTests(LdapTestCase): 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") @@ -251,9 +252,8 @@ class ResetPasswordTests(LdapTestCase): self.assertFalse(self.can_bind(victim, "attacker-1234", ou="users"), "account takeover without any token") - @expectedFailure - def test_bug_ldap_filter_injection_in_reset(self): - """CRITICAL: user='*' matches the first entry in the whole tree.""" + 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"}) diff --git a/dal/ungleich_ldap.py b/dal/ungleich_ldap.py index 1dedbc9..c594399 100644 --- a/dal/ungleich_ldap.py +++ b/dal/ungleich_ldap.py @@ -4,6 +4,8 @@ import random import ldap3 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__) @@ -93,7 +95,7 @@ class LdapManager: try: uid = user # user.encode("utf-8") conn.add("uid={uid},{customer_dn}".format( - uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN + uid=escape_rdn(uid), customer_dn=settings.LDAP_CUSTOMER_DN ), ["inetOrgPerson", "posixAccount", "ldapPublickey"], { @@ -216,7 +218,7 @@ class LdapManager: result = conn.search( search_base=search_base, search_filter=search_filter if len(search_filter)> 0 else - '(uid={uid})'.format(uid=uid), + '(uid={uid})'.format(uid=escape_filter_chars(uid)), attributes=attributes ) entries = conn.entries @@ -234,7 +236,7 @@ class LdapManager: conn = self.get_admin_conn() try: return_val = conn.delete( - ("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid), + ("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=escape_rdn(uid)), ) msg = "success" except Exception as ex: diff --git a/dal/views.py b/dal/views.py index 2a81cfb..3d814f8 100644 --- a/dal/views.py +++ b/dal/views.py @@ -37,7 +37,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin # Small helper used for registration. def is_username_valid(username): - return re.fullmatch(r"^[a-z|0-9|\-|_]+$", username) + return re.fullmatch(r"[a-z0-9_-]+", username) admin_seed = config('ADMIN_SEED') admin_name = config('ADMIN_NAME') From 23ddf33d411fbaf1abab82d7b7211f8b13a59732 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:01:47 +0300 Subject: [PATCH 04/51] Require a verified reset link to set a new password ResetRequest.post took the username from a hidden form field and changed that user's password without checking any token, so anyone could take over any account with a single POST to /reset/. Opening a valid reset link now stores the verified username in the session (valid for one hour, cleared after use). The POST only acts on that user and ignores the form field. Reset tokens now come from secrets.token_urlsafe, and malformed links return 404 instead of 500. --- dal/tests/test_flows.py | 31 +++++++++++++++++++++++++++--- dal/views.py | 42 ++++++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index a440320..969ea37 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -237,15 +237,40 @@ class ResetPasswordTests(LdapTestCase): 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") - @expectedFailure - def test_bug_reset_post_requires_a_valid_token(self): - """CRITICAL: POST /reset/ sets any user's password without a token.""" + 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"}) diff --git a/dal/views.py b/dal/views.py index 3d814f8..429c53e 100644 --- a/dal/views.py +++ b/dal/views.py @@ -19,6 +19,7 @@ from pyotp import TOTP import logging import re +import secrets logger = logging.getLogger(__name__) @@ -45,6 +46,11 @@ admin_realm = config('ADMIN_REALM') user_realm = config('USER_REALM') otp_url = config('OTPSERVER') +# 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 + def activate_account_link(base_url, user, pwd, firstname, lastname, email, epochutc): tokengen = PasswordResetTokenGenerator() @@ -332,10 +338,7 @@ 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 - tokengen = PasswordResetTokenGenerator() - # create some noise for use in the tokengenerator - pseudouser = PseudoUser() - token = tokengen.make_token(pseudouser) + token = secrets.token_urlsafe(32) buser = bytes(user, 'utf-8') userpart = b64encode(buser) # create entry into the database @@ -364,9 +367,10 @@ class ResetRequest(View): if user == None or token == None: return HttpResponse('Invalid URL.', status=404) # extract user from b64 format - tmp_user = bytes(user, 'utf-8') - user = b64decode(tmp_user) - user_clean = user.decode('utf-8') + try: + user_clean = b64decode(user.encode('utf-8')).decode('utf-8') + except Exception: + return HttpResponse('Invalid URL.', status=404) # set checks_out = True if token is found in database checks_out = False dbentries = ResetToken.objects.all().filter(user=user_clean) @@ -378,9 +382,13 @@ class ResetRequest(View): # No token was found if not checks_out: return HttpResponse('Invalid URL.', status=404) - # Token was found, supply the form - else: - return render(request, 'resetpasswordnew.html', { 'user': user_clean } ) + # 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': int(datetime.utcnow().timestamp()), + } + return render(request, 'resetpasswordnew.html', { 'user': user_clean } ) # Gets the post form with the new password and sets it @@ -389,11 +397,14 @@ class ResetRequest(View): # get the supplied passwords password1 = request.POST.get("password1") password2 = request.POST.get("password2") - # 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?' } ) + # 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 = int(datetime.utcnow().timestamp()) - 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.' } ) 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: @@ -409,6 +420,7 @@ 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: From dad66040edfd27902f87196155d9ae738141cee8 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:02:41 +0300 Subject: [PATCH 05/51] Keep passwords out of activation links The activation link carried base64-encoded username, password, names and email, so plaintext passwords ended up in mail archives, browser history and proxy logs. Registration now hashes the password to {SSHA} immediately and stores it in a new PendingRegistration table (migration 0002). The emailed link is /activate// with a random token only, and activation writes the stored hash into LDAP. Pending entries expire after 24h, like reset tokens. This also drops the PseudoUser/token-generator workaround. Links in emails sent before deployment stop working; those users must register again. --- dal/migrations/0002_pendingregistration.py | 26 ++++++ dal/models.py | 16 ++++ dal/tests/test_flows.py | 14 +-- dal/ungleich_ldap.py | 11 ++- dal/urls.py | 2 +- dal/views.py | 102 +++++---------------- 6 files changed, 81 insertions(+), 90 deletions(-) create mode 100644 dal/migrations/0002_pendingregistration.py diff --git a/dal/migrations/0002_pendingregistration.py b/dal/migrations/0002_pendingregistration.py new file mode 100644 index 0000000..1e687ba --- /dev/null +++ b/dal/migrations/0002_pendingregistration.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.28 on 2026-11-06 12:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dal', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PendingRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=64, unique=True)), + ('username', models.CharField(max_length=100)), + ('email', models.EmailField(max_length=254)), + ('firstname', models.CharField(max_length=255)), + ('lastname', models.CharField(max_length=255)), + ('password_hash', models.CharField(max_length=255)), + ('creation', models.BigIntegerField()), + ], + ), + ] diff --git a/dal/models.py b/dal/models.py index 676b103..eeae4df 100644 --- a/dal/models.py +++ b/dal/models.py @@ -17,6 +17,22 @@ class ResetToken(models.Model): creation = models.BigIntegerField() +class PendingRegistration(models.Model): + """A sign-up waiting for its email to be confirmed. + + Only the {SSHA} hash of the password is kept, and the activation link + carries nothing but the random token. + """ + token = models.CharField(max_length=64, unique=True) + username = models.CharField(max_length=100) + email = models.EmailField(max_length=254) + firstname = models.CharField(max_length=255) + lastname = models.CharField(max_length=255) + password_hash = models.CharField(max_length=255) + # creation time in epoch (UTC), like ResetToken + creation = models.BigIntegerField() + + def get_validation_slug(): return make_password(None) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index 969ea37..867f949 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -109,9 +109,7 @@ class RegisterTests(LdapTestCase): data = self.form() self.track(data["username"]) self.client.post(REGISTER, data) - link = self.last_mail_link().rstrip("/") - bad = link.rsplit("/", 1)[0] + "/not-the-token/" - self.assertEqual(self.client.get(bad).status_code, 404) + 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): @@ -124,15 +122,19 @@ class RegisterTests(LdapTestCase): uids.append(int(self.ldap_entry(data["username"]).uidNumber.value)) self.assertNotEqual(uids[0], uids[1]) - @expectedFailure - def test_bug_password_not_in_activation_link(self): - """SECURITY: link carries base64(password); it lands in mail/proxy logs.""" + 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) @expectedFailure def test_bug_cannot_register_name_taken_by_staff(self): diff --git a/dal/ungleich_ldap.py b/dal/ungleich_ldap.py index c594399..5364055 100644 --- a/dal/ungleich_ldap.py +++ b/dal/ungleich_ldap.py @@ -72,7 +72,12 @@ class LdapManager: return passwd - def create_user(self, user, password, firstname, lastname, email): + 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 create_user(self, user, password_hash, firstname, lastname, email): + """Create a customer entry. *password_hash* comes from hash_password().""" conn = self.get_admin_conn() uidNumber = self._get_max_uid() + 1 logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber)) @@ -109,9 +114,7 @@ class LdapManager: "loginShell": ["/bin/bash"], "homeDirectory": ["/home/{}".format(user).encode("utf-8")], "mail": email.encode("utf-8"), - "userPassword": [self._ssha_password( - password.encode("utf-8") - )] + "userPassword": [password_hash.encode("ascii")] } ) logger.debug('Created user %s %s' % (user.encode('utf-8'), diff --git a/dal/urls.py b/dal/urls.py index 4c86376..2ebbded 100644 --- a/dal/urls.py +++ b/dal/urls.py @@ -30,7 +30,7 @@ urlpatterns = [ path('index/', Index.as_view(), name="index"), path('logout/', LogOut.as_view(), name="logout"), path('reset///', ResetRequest.as_view()), - path('activate///////', ActivateAccount.as_view()), + path('activate//', ActivateAccount.as_view(), name="activate"), path('reset/', ResetRequest.as_view(), name="reset"), path('otp/', SeedRetrieveCreate.as_view(), name="seed"), path('', Index.as_view(), name="login_index"), diff --git a/dal/views.py b/dal/views.py index 429c53e..758758a 100644 --- a/dal/views.py +++ b/dal/views.py @@ -6,12 +6,11 @@ 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.views.decorators.cache import cache_control from rest_framework.views import APIView from rest_framework.response import Response -from .models import ResetToken +from .models import PendingRegistration, ResetToken from .forms import LoginForm from .ungleich_ldap import LdapManager from decouple import config, Csv @@ -28,8 +27,6 @@ logger = logging.getLogger(__name__) from base64 import b64encode, b64decode from datetime import datetime -from random import choice, randint -import string import requests import json @@ -53,32 +50,17 @@ RESET_SESSION_MAX_AGE = 60 * 60 def activate_account_link(base_url, user, pwd, firstname, lastname, email, 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 + """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, ) - return link + return "{base_url}/activate/{token}/".format(base_url=base_url, token=token) def clean_db(): @@ -87,10 +69,8 @@ def clean_db(): # using utcnow() to have no headache with timezones cutoff = int(datetime.utcnow().timestamp()) - (24*60*60) # Get all tokens older than 24 hours - oldtokens = ResetToken.objects.all().filter(creation__lt=cutoff) - for token in oldtokens: - # delete all tokens older than 24 hours - token.delete() + ResetToken.objects.filter(creation__lt=cutoff).delete() + PendingRegistration.objects.filter(creation__lt=cutoff).delete() return True class Index(FormView): @@ -533,74 +513,38 @@ 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, user=None, pwd=None, firstname=None, lastname=None, email=None, token=None): + def get(self, request, token=None): clean_db() - 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: + pending = PendingRegistration.objects.filter(token=token).first() + if pending is None: return HttpResponse('Invalid URL.', status=404) - # Token was found, create user + # single use: consumed whether or not the creation below succeeds + pending.delete() try: ldap_manager = LdapManager() ldap_manager.create_user( - clean_list[0], clean_list[1], clean_list[2], clean_list[3], clean_list[4] + pending.username, pending.password_hash, + pending.firstname, pending.lastname, pending.email ) 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], + 'name': pending.username, '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])) + logger.error("User {} failed to create its otp seed".format(pending.username)) #Send welcome email except Exception as e: return render(request, 'error.html', {'urlname': 'register', 'service': 'register an user', 'error': e}) - return render(request, 'usercreated.html', { 'user': clean_list[0] } ) + return render(request, 'usercreated.html', { 'user': pending.username } ) class UserCreateAPI(APIView): From f675ce9e31ee63a869e0ec0fd7be06d7370b8e5e Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:03:14 +0300 Subject: [PATCH 06/51] Reject usernames that exist anywhere in the directory Registration only collided inside ou=customers, so a staff username from ou=users could be registered again as a customer, creating two accounts with the same uid. Check the whole tree (ENTIRE_SEARCH_BASE) on the register form, in the create API, and again at activation in case the name was taken in between. --- dal/tests/test_flows.py | 29 ++++++++++++++++++++++------- dal/ungleich_ldap.py | 10 ++++++++++ dal/views.py | 9 +++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index 867f949..d61918f 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -1,12 +1,10 @@ """ End-to-end behaviour of the account webapp against a real LDAP server. -Tests decorated with @expectedFailure document known bugs found during the -upgrade audit. When a bug is fixed the test starts passing, Django reports an -"unexpected success", and the decorator must be removed. +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 unittest import expectedFailure - from django.core import mail from .base import LdapTestCase, unique_uid @@ -136,8 +134,7 @@ class RegisterTests(LdapTestCase): self.assertTrue(stored.password_hash.startswith("{SSHA}")) self.assertNotIn("Very-Secret-pw-1", stored.password_hash) - @expectedFailure - def test_bug_cannot_register_name_taken_by_staff(self): + def test_cannot_register_name_taken_by_staff(self): """Username in ou=users must not be re-registrable under ou=customers.""" staff = self.make_ldap_user(ou="users") self.track(staff) # in case the bug creates a customer duplicate @@ -147,6 +144,20 @@ class RegisterTests(LdapTestCase): self.assertIsNone( self.ldap_entry_in(staff, "ou=customers,dc=ungleich,dc=ch")) + 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, "ou=customers,dc=ungleich,dc=ch")) + def ldap_entry_in(self, uid, base): from .base import admin_conn conn = admin_conn() @@ -327,6 +338,10 @@ 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) diff --git a/dal/ungleich_ldap.py b/dal/ungleich_ldap.py index 5364055..4d1c0e8 100644 --- a/dal/ungleich_ldap.py +++ b/dal/ungleich_ldap.py @@ -76,8 +76,18 @@ class LdapManager: """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)) conn = self.get_admin_conn() uidNumber = self._get_max_uid() + 1 logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber)) diff --git a/dal/views.py b/dal/views.py index 758758a..3a3531c 100644 --- a/dal/views.py +++ b/dal/views.py @@ -117,6 +117,12 @@ 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: @@ -561,6 +567,9 @@ 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: From 0cb0344ac8f464ea78b37b3ac952f2cdf54fdda6 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:03:57 +0300 Subject: [PATCH 07/51] Compute the next uidNumber from LDAP instead of a local file The highest uidNumber was cached in dal/ldap_max_uid_file. That breaks with more than one replica and is lost on every container restart. The uniqueness check behind it searched base '' (the root DSE), which OpenLDAP never matches, so nothing caught a stale file. The error path also referenced a setting that does not exist. Now a paged search over ENTIRE_SEARCH_BASE finds the highest uidNumber on each activation. Numbering is unchanged: the next number is above both LDAP_DEFAULT_START_UID and every existing uidNumber. LDAP_MAX_UID_FILE_PATH is no longer used. --- .gitignore | 1 - dal/settings.py | 6 ---- dal/tests/base.py | 12 +------ dal/tests/test_flows.py | 13 +++++++ dal/ungleich_ldap.py | 80 +++++++++++++++-------------------------- 5 files changed, 43 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index 38b9c32..712a31c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,3 @@ secret-key *.mo venv/ -dal/ldap_max_uid_file diff --git a/dal/settings.py b/dal/settings.py index e39373f..74dc14f 100644 --- a/dal/settings.py +++ b/dal/settings.py @@ -29,12 +29,6 @@ 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_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) # Search union over OUs diff --git a/dal/tests/base.py b/dal/tests/base.py index 3939495..a4a10ab 100644 --- a/dal/tests/base.py +++ b/dal/tests/base.py @@ -10,16 +10,14 @@ 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 os import re -import tempfile import uuid from unittest import mock import ldap3 from django.conf import settings from django.core import mail -from django.test import TestCase, override_settings +from django.test import TestCase from dal.ungleich_ldap import LdapManager @@ -60,11 +58,6 @@ class LdapTestCase(TestCase): def setUp(self): ensure_ldap_up() self._created_dns = [] - fd, self.uid_file = tempfile.mkstemp(prefix="ldap_max_uid_") - os.close(fd) - os.unlink(self.uid_file) # app must cope with a missing file - self._settings = override_settings(LDAP_MAX_UID_FILE_PATH=self.uid_file) - self._settings.enable() # 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="{}") @@ -77,9 +70,6 @@ class LdapTestCase(TestCase): for dn in self._created_dns: conn.delete(dn) conn.unbind() - self._settings.disable() - if os.path.exists(self.uid_file): - os.unlink(self.uid_file) # LDAP helpers --------------------------------------------------------- diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index d61918f..aab0189 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -120,6 +120,19 @@ class RegisterTests(LdapTestCase): 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("dc=ungleich,dc=ch", "(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 diff --git a/dal/ungleich_ldap.py b/dal/ungleich_ldap.py index 4d1c0e8..044bc6a 100644 --- a/dal/ungleich_ldap.py +++ b/dal/ungleich_ldap.py @@ -88,25 +88,9 @@ class LdapManager: """Create a customer entry. *password_hash* comes from hash_password().""" if self.username_taken(user): raise Exception("The username {} is already taken.".format(user)) - conn = self.get_admin_conn() - uidNumber = self._get_max_uid() + 1 + uidNumber = self._next_uid_number() 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) + conn = self.get_admin_conn() try: uid = user # user.encode("utf-8") conn.add("uid={uid},{customer_dn}".format( @@ -260,40 +244,34 @@ class LdapManager: conn.unbind() return return_val, msg - def _set_max_uid(self, max_uid): + def _next_uid_number(self): """ - a utility function to save max_uid value to a file + Return the next free uidNumber: one above the highest uidNumber in + the whole tree, and above LDAP_DEFAULT_START_UID. - :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 + 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. """ + highest = settings.LDAP_DEFAULT_START_UID + conn = self.get_admin_conn() try: - 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 + 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 From 15af9f8d9c0e73776690f0e427122678dbd10379 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:04:23 +0300 Subject: [PATCH 08/51] Stop writing passwords and reset links to logs SeedRetrieveCreate printed every submitted password to stdout, so it ended up in container and server logs. The reset view logged complete reset URLs at debug level, and those work as credentials until used. --- dal/tests/test_flows.py | 13 +++++++++++++ dal/views.py | 2 -- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dal/tests/test_flows.py b/dal/tests/test_flows.py index aab0189..43e53ef 100644 --- a/dal/tests/test_flows.py +++ b/dal/tests/test_flows.py @@ -387,6 +387,19 @@ class OtpSeedTests(LdapTestCase): 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_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"}) diff --git a/dal/views.py b/dal/views.py index 3a3531c..697175f 100644 --- a/dal/views.py +++ b/dal/views.py @@ -334,7 +334,6 @@ 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 @@ -617,7 +616,6 @@ class SeedRetrieveCreate(APIView): 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 From 4f00971f84be8ed60468f544f573879867f67065 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 6 Nov 2026 15:05:27 +0300 Subject: [PATCH 09/51] Use {% load static %} in templates {% load staticfiles %} is deprecated in Django 2.2 and removed in 3.0. --- dal/templates/base.html | 2 +- dal/templates/base_short.html | 2 +- dal/templates/changeddata.html | 2 +- dal/templates/changedpassword.html | 2 +- dal/templates/changepassword.html | 2 +- dal/templates/changeuserdata.html | 2 +- dal/templates/confirm_email.html | 2 +- dal/templates/deleteaccount.html | 2 +- dal/templates/deleteduser.html | 2 +- dal/templates/error.html | 2 +- dal/templates/hosting_login.html | 2 +- dal/templates/includes/_header.html | 2 +- dal/templates/includes/_navbar.html | 2 +- dal/templates/includes/_navbar_user.html | 2 +- dal/templates/includes/_our_infrastructure.html | 2 +- dal/templates/includes/_pricing.html | 2 +- dal/templates/includes/_your_infrastructure.html | 2 +- dal/templates/landing.html | 2 +- dal/templates/loginfailed.html | 2 +- dal/templates/mustbeloggedin.html | 2 +- dal/templates/registeruser.html | 2 +- dal/templates/resetpassword.html | 2 +- dal/templates/resetpasswordnew.html | 2 +- dal/templates/seed_list.html | 2 +- dal/templates/send_resetrequest.html | 2 +- dal/templates/usercreated.html | 2 +- dal/templates/useroptions.html | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dal/templates/base.html b/dal/templates/base.html index 030afb5..d01d835 100644 --- a/dal/templates/base.html +++ b/dal/templates/base.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %} diff --git a/dal/templates/base_short.html b/dal/templates/base_short.html index dd9e131..a60825c 100644 --- a/dal/templates/base_short.html +++ b/dal/templates/base_short.html @@ -1,4 +1,4 @@ -{% load staticfiles i18n %} +{% load static i18n %} diff --git a/dal/templates/changeddata.html b/dal/templates/changeddata.html index 3764ac3..580c95e 100644 --- a/dal/templates/changeddata.html +++ b/dal/templates/changeddata.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Success! diff --git a/dal/templates/changedpassword.html b/dal/templates/changedpassword.html index d9ba591..26251d0 100644 --- a/dal/templates/changedpassword.html +++ b/dal/templates/changedpassword.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Password Updated. diff --git a/dal/templates/changepassword.html b/dal/templates/changepassword.html index c6f61e6..f7b66c8 100644 --- a/dal/templates/changepassword.html +++ b/dal/templates/changepassword.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Changing the password for {{user}} diff --git a/dal/templates/changeuserdata.html b/dal/templates/changeuserdata.html index 8878ca2..7306b3c 100644 --- a/dal/templates/changeuserdata.html +++ b/dal/templates/changeuserdata.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Changing user data for {{user}} diff --git a/dal/templates/confirm_email.html b/dal/templates/confirm_email.html index 729af28..c48061e 100644 --- a/dal/templates/confirm_email.html +++ b/dal/templates/confirm_email.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Verify your email. diff --git a/dal/templates/deleteaccount.html b/dal/templates/deleteaccount.html index 3c74052..8a075e7 100644 --- a/dal/templates/deleteaccount.html +++ b/dal/templates/deleteaccount.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Deleting Account diff --git a/dal/templates/deleteduser.html b/dal/templates/deleteduser.html index d0c4330..4145a38 100644 --- a/dal/templates/deleteduser.html +++ b/dal/templates/deleteduser.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} Deleted user {{user}} diff --git a/dal/templates/error.html b/dal/templates/error.html index 6726430..aee7a36 100644 --- a/dal/templates/error.html +++ b/dal/templates/error.html @@ -1,5 +1,5 @@ {% extends "base_short.html" %} -{% load i18n staticfiles bootstrap3 %} +{% load i18n static bootstrap3 %} {% block title %} An error has occurred! diff --git a/dal/templates/hosting_login.html b/dal/templates/hosting_login.html index 8a52ae1..e4b3660 100644 --- a/dal/templates/hosting_login.html +++ b/dal/templates/hosting_login.html @@ -1,5 +1,5 @@ {% extends "hosting/base_short.html" %} -{% load i18n staticfiles %} +{% load i18n static %} {% block navbar %} {% include 'hosting/includes/_navbar_transparent.html' %} diff --git a/dal/templates/includes/_header.html b/dal/templates/includes/_header.html index 6f9b4a5..d4c6a82 100644 --- a/dal/templates/includes/_header.html +++ b/dal/templates/includes/_header.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %}
diff --git a/dal/templates/includes/_navbar.html b/dal/templates/includes/_navbar.html index 44b1bf5..694c67a 100644 --- a/dal/templates/includes/_navbar.html +++ b/dal/templates/includes/_navbar.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %}