For speed of development and future maintainability, I'm revisiting working with Django. Some things have changed, but their tutorial is still lacking in a few areas, so I'm posting my journey, and hopefully this will result in a Github repo. As always, this is mostly for my own reference, but if it helps anyone else out, great!

I'm assuming Python 3.7.x and Django 2.2. I'm also doing my development in Windows, and using VS Code as my editor.

Pick a directory, any directory, and create yourself a virtual environment, and enter it. Install Django. Update pip if necessary or if you feel like it.

python -m venv test-env
test-env\Scripts\activate.bat
python -m pip install --upgrade pip
pip install Django

I need LDAP, so here's what I need to do, because I'm on Windows: download the appropriate wheel for python-ldap and install it using pip. Then install django-auth-ldap.

pip install path/to/python_ldap-3.2.0-*.whl
pip install django-auth-ldap

Again, because I'm going to be using a pre-existing database, I need MySQL support.

pip install mysqlclient

Here's what I wind up with (pip list):

Package Version
Django 2.2.4
django-auth-ldap 2.0.0
mysqlclient 1.4.2
pip 19.2.1
pyasn1 0.4.6
pyasn1-modules 0.2.6
python-ldap 3.2.0
pytz 2019.2
setuptools 40.8.0
sqlparse 0.3.0

Time to make the actual project:

django-admin startproject mysite
cd mysite

For ease of use in Windows, let's make a manage.bat file:

echo python manage.py %* > manage.bat

Okay, here's the first major departure from the tutorial: make a custom user. Not everyone needs one, but a) it's a good idea, b) the Django project recommends it, and c) it's much harder to integrate later. Thing is, despite recommending it, they do a terrible job of describing how to actually do it.

manage.bat startapp myuser

Let's start simple, and just copy-pasta:

myuser.models:

from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.
class User(AbstractUser):
    pass

myuser.admin:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User

# Register your models here.
admin.site.register(User, UserAdmin)

mysite.settings:

INSTALLED_APPS = [
    ...
    'myuser.apps.MyuserConfig',
]
AUTH_USER_MODEL = 'myuser.User'

Now, run the following:

manage.bat makemigrations
manage.bat migrate
manage.bat createsuperuser

(answer the questions, you should know the drill)

manage.bat runserver

and visit http://localhost:8000/admin

Huzzah! instead of "Users" you should have "Myuser" with "Users" underneath!

Okay, now we need to integrate LDAP. Add the following to your settings. Yes, it's a lot, but it's necessary:

import ldap
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion

AUTHENTICATION_BACKENDS = (
    'django_auth_ldap.backend.LDAPBackend',
    'django.contrib.auth.backends.ModelBackend',
)
AUTH_LDAP_SERVER_URI = 'ldaps://ldap.example.com'
AUTH_LDAP_GLOBAL_OPTIONS = {
    relevant_options
}
AUTH_LDAP_BIND_DN = 'django_bind_user'
AUTH_LDAP_BIND_PASSWORD = 'password'
AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(
    LDAPSearch('ou=users,o=example.com', ldap.SCOPE_SUBTREE, "(uid=%(user)s)"),
    LDAPSearch('ou=users,o=example.com', ldap.SCOPE_SUBTREE, "(cn=%(user)s)")
)
AUTH_LDAP_USER_ATTR_MAP = {
    'email':'mail',
    'last_name':'sn',
    'first_name':'givenName',
}

In AUTHENTICATION_BACKENDS, and in LDAPSearchUnion, the order matters. The first one that works, it will use. So if you have LDAP first, and you created a superuser with something that will match in the LDAP search, you won't likely be able to log in to the admin site. But either way, you can't have anyone log in yet. You need to set up URLs and templates and stuff. None of which Django provides. I'm here to help.

In mysite.settings.TEMPLATES.DIRS, add os.path.join(BASE_DIR, 'templates') to the list.

Make a templates directory in the same directory as myuser. Create an empty base.html in this directory (we'll add some stuff to it in a bit). Make a myuser directory inside templates. Create login.html and logout.html files here.

Okay, let's add some content. This will be barebones, but it'll be functional.

tempates/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>{% block title %}My Site{% endblock title %}</title>
</head>
<body>
  {% block main %}
  {% endblock main %}
</body>
</html>

(I usually put a lot more stuff in there, but we're going for getting it done right now, not making it pretty)

templates/myuser/login.html:

{% extends 'base.html' %}

{% block main %}
<form method="post">
  {% csrf_token %}
  <h2>Please sign in</h2>
  {% if form.errors %}
    <p>Your username and password did not match. Please try again.</p>
  {% endif %}
  <div>
    <label for="{{ form.username.id_for_label }}">{{ form.username.label }}</label>
    <input type="text" id="{{ form.username.id_for_label }}" name="{{ form.username.name }}" required autofocus>
  </div>
  <div>
    <label for="{{ form.password.id_for_password }}">{{ form.password.label }}</label>
    <input type="password" id="{{ form.password.id_for_password }}" name="{{ form.password.name }}" required>
  </div>
  <button type="submit" value="login">Sign in</button>
</form>
{% endblock main %}

templates/myuser/logout.html:

{% extends 'base.html' %}

{% block main %}
<div>
  <h2>Thank you</h2>
  <p>Thank you for visiting the website.</p>
  <p><a href="{% url 'myuser:login' %}">Log in again?</a></p>
</div>
{% endblock main %}

Now we've got some Python to create/edit:

myuser.urls (a new file):

from django.conf.urls import url
from django.contrib.auth import views as auth_views

app_name='myuser'
urlpatterns = [
    url(r'^login/$', auth_views.LoginView.as_view(template_name='myuser/login.html'), name='login'),
    url(r'^logout/$', auth_views.LogoutView.as_view(template_name='myuser/logout.html'), name='logout'),
]

mysite.urls:

from django.urls import path, include

urlpatterns = [
    path('account/', include('myuser.urls')
    ...
]

mysite.settings:

LOGIN_URL = 'myuser:login'
LOGOUT_URL = 'myuser:logout'

Now you're ready to test things out. Visit http://localhost:8000/account/login. First log in with your superuser account. You should wind up with a 404, but we don't care about that now. Now go to http://localhost:8000/account/logout. Make sure the "Log in again?" link works. Now log in with some LDAP credentials. Again, you should wind up with a 404. Log out again, and visit the admin page.

Log in with your superuser credentials, and check the "Users" list under "Myuser". Make sure the appropriate fields were mapped to the correct places.

All good? Good.

But let's say you're in the unfortunate position of having multiple valid log in names attached to the same user (like me). How do we normalize this? We could set up the AUTH_LDAP_USER_ATTR_MAP to include username as a field, but unfortunately for our issue (though it's often useful in practice) these fields are updated every time the user at issue logs in, and Django will throw a fit, claiming you're trying to create a new record with a duplicate user ids when you're just trying to log in using a username you've used before. Once you've exhausted all possible usernames, you're effectively locked out of the system. To solve this, we have to write our own back end for logging in. Remember AUTHENTICATION_BACKENDS? Well, now we get to write our own.

myuser.backends (new file):

from django_auth_ldap.backend import LDAPBackend
from django.contrib.auth.backends import ModelBackend

from django_auth_ldap.backend import LDAPBackend

class DjangoLDAPBackend(LDAPBackend):
    def get_or_build_user(self, username, ldap_user):
        model = self.get_user_model()
        username_field = getattr(model, 'USERNAME_FIELD', 'username')
        un = ldap_user.attrs['mail'][0]
        kwargs = {
            username_field + '__iexact': un,
            'defaults': {username_field: un.lower()}
        }
        user = model.objects.get_or_create(**kwargs)
        return user

Basically, what this does is look up the user, but change the username from whatever was entered to something standardized, in this case, the email address. And it does a case-insensitive comparison to what's on the authentication server.

mysite.settings:

AUTHENTICATION_BACKENDS = (
    'myuser.backends.DjangoLDAPBackend',
    # 'django_auth_ldap.backend.LDAPBackend',
    'django.contrib.auth.backends.ModelBackend',
)

One more thing: what if you need to store a handful more attributes from the LDAP server? Or what if you want to be able to store more info about a user? Let's go back to myuser.models and make some tweaks.

Change AbstractUser to AbstractBaseUser, then specify your fields. This is going to feel redundant: didn't these fields already exist before? Yes, but trust me, though this is going to be a decent amount of work, it's the right way to do it. Hopefully the Django project provides a better skeleton of the user stuff in the future.

class User(AbstractBaseUser):
    email=models.EmailField(unique=True)
    first_name = models.CharField(max_length=50)
    last_name=models.CharField(max_length=50)
    initials=models.CharField(max_length=3,blank=True)
    employee_number=models.CharField(max_length=10,blank=True)
    is_admin = models.BooleanField(default=False)
    USERNAME_FIELD='email'

We've dropped username as a field (redefining email as the username), for my application I don't need is_staff or is_superuser (a single is_admin will suffice), and I'm grabbing a couple of other things from LDAP. Don't forget to add those to AUTH_LDAP_USER_ATTR_MAP. However, using this as our user model won't quite work yet. I still want to use the built-in admin interface, so even though I don't want or need it as a field, is_staff has to return something. Likewise, even though these mean very little to my application, I have to define has_perm and has_module_perms, or subclass PermissionsMixin. I'm going to do the latter, partly because the documentation isn't very good.

Also, because we're not subclassing AbstractUser, we need to define a User Manager. I won't go into a lot of detail here, but it defines the create_user and create_superuser methods, which we've stomped all over by changing the username and other attributes of users in general.

More copy-pasta:

class UserManager(BaseUserManager):
    def create_user(self, email, password=None):
        if not email:
            raise ValueError('User must have an email address')
        user = self.model(email=self.normalize_email(email))
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password):
        user = self.create_user(email, password=password)
        user.is_admin = True
        user.save(using=self._db)
        return user


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    initials = models.CharField(max_length=3, blank=True)
    employee_number = models.CharField(max_length=10, blank=True)
    is_admin = models.BooleanField(default=False)
    USERNAME_FIELD = 'email'
    objects = UserManager()
    @property
    def is_staff(self):
        return self.is_admin

    @property
    def is_superuser(self):
        return self.is_admin

    def get_short_name(self):
        return self.first_name

    def get_full_name(self):
        return '{} {}'.format(self.first_name, self.last_name)

After migrating, you'll likely find that your old superuser account no longer works. You could start from scratch by demolishing all your existing migrations and the database, but let's be a little less...drastic about it. Create a new one!

manage createsuperuser

Answer the prompts like before.

You're still going to have issues if you want to play around in the admin interface, because we used UserAdmin. Take it out of admin.py and you'll be okay, but let's fix that up:

myuser.admin

from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import ReadOnlyPasswordHashField
from .models import User

# Register your models here.


class UserCreationForm(forms.ModelForm):
    password1 = forms.CharField(label='Password', widget=forms.PasswordInput)
    password2 = forms.CharField(label='Password confirmation', widget=forms.PasswordInput)

    class Meta:
        model = User
        fields = ('email',)

    def clean_password2(self):
        password1 = self.cleaned_data.get('password1')
        password2 = self.cleaned_data.get('password2')
        if password1 and password2 and password1 != password2:
            raise forms.ValidationError("Passwords don't match")
        return password2

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data['password1'])
        if commit:
            user.save()
        return user


class UserChangeForm(forms.ModelForm):
    password = ReadOnlyPasswordHashField()

    class Meta:
        model = User
        fields = ('email', 'password', 'first_name', 'last_name', 'initials', 'display_name',
                  'employee_number', 'last_login', 'is_admin')

    def clean_password(self):
        return self.initial["password"]


class UserAdmin(BaseUserAdmin):
    form = UserChangeForm
    add_form = UserCreationForm

    list_display = ('display_name', 'email', 'is_admin')
    list_filter = ('is_admin',)
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        ('Personal info', {'fields': ('first_name', 'last_name', 'initials', 'display_name',)}),
        ('Company info', {'fields': ('employee_number',)}),
        ('Permissions', {'fields': ('is_admin',)}),
    )
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('email', 'password1', 'password2')}
         ),
    )
    search_fields = ('email',)
    ordering = ('email',)
    filter_horizontal = ()


admin.site.register(User, UserAdmin)

You may want to make those fields read-only or not include them at all if they're updated from LDAP, but it's a good start.

Please note that, despite showing the password field(s) here, LDAP authentication happens first, and you cannot use the Django admin interface to change your LDAP password. Well, not easily, and I'm not planning on going to that trouble anytime soon, or probably ever.

So how has all this helped us? Well, you now have a fully-extensible user model that you should be able to plug-and-play into most of your Django projects. Hopefully you've learned something, too. I have a very bruised forehead from working this out, so I hope I've saved you from a similar fate.