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

class User(AbstractUser):
pass


from django.contrib import admin
from .models import User



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


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_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">
<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>
<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)

{% extends 'base.html' %}

{% block main %}
<form method="post">
{% csrf_token %}
{% if form.errors %}
{% endif %}
<div>
</div>
<div>
</div>
</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>
</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'


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):
model = self.get_user_model()
un = ldap_user.attrs['mail'][0]
kwargs = {
}
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)


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):
if not email:
raise ValueError('User must have an email address')
user = self.model(email=self.normalize_email(email))
user.save(using=self._db)
return user

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)
objects = UserManager()
@property
def is_staff(self):

@property
def is_superuser(self):

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


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:

from django import forms
from .models import User

class UserCreationForm(forms.ModelForm):

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

def save(self, commit=True):
user = super().save(commit=False)
if commit:
user.save()
return user

class UserChangeForm(forms.ModelForm):

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

form = UserChangeForm

fieldsets = (
('Personal info', {'fields': ('first_name', 'last_name', 'initials', 'display_name',)}),
('Company info', {'fields': ('employee_number',)}),
)
(None, {
'classes': ('wide',),
),
)
search_fields = ('email',)
ordering = ('email',)
filter_horizontal = ()