Django LDAP
==========

The latest guidance for installation can be found at https://django-auth-ldap.readthedocs.io/en/latest/install.html

CUBRIC's setup has been tested on Django version 2 -> 4 (LTS). The Docker image used at the time of writing is `python:3.11.5-slim`.

## Packages
Install the following packages on Ubuntu / Debian-based hosts
```
apt install ca-certificates libldap2-dev
```

## Installation
Install the following Python packages
```
pip install django-auth-ldap python-ldap
```

On the host/container, add the LDAP certificate
```
cp Cardiff-University-Root-Web-Combine.pem /usr/local/share/ca-certificates/ && update-ca-certificates
```

Add the following to settings.py
```
import ldap
import logging
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType, LDAPGroupQuery

# Troubleshooting
logger = logging.getLogger("django_auth_ldap")
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.DEBUG)

# LDAP
# Baseline configuration.
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
AUTH_LDAP_SERVER_URI = "ldaps://ldap-auth.cf.ac.uk"
AUTH_LDAP_BIND_DN = <ldap-bind-account>    # provided by server team
AUTH_LDAP_BIND_PASSWORD = <ldap-bind-password>
AUTH_LDAP_USER_SEARCH = LDAPSearch("o=CF", ldap.SCOPE_SUBTREE, "(uid=%(user)s)")

# Search for users in Cardiff University
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
    "o=CF",
    ldap.SCOPE_SUBTREE,
    "(objectClass=inetorgperson)",
)

AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn")

# Simple group restrictions
# AUTH_LDAP_REQUIRE_GROUP = "cn=CUBRIC-All,ou=PSYCH,ou=AdHocGroups,ou=Groups,o=Resources"
# AUTH_LDAP_DENY_GROUP = "cn=disabled,ou=django,ou=groups,dc=example,dc=com"

# Populate the Django user from the LDAP directory.
AUTH_LDAP_USER_ATTR_MAP = {
    # django built-in user model fields -> ldap attributes
    "username": "uid",
    "first_name": "givenName",
    "last_name": "sn",
    "email": "mail",
}

# https://django-auth-ldap.readthedocs.io/en/latest/groups.html#limiting-access
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    # "is_active": "cn=CUBRIC-All,ou=PSYCH,ou=AdHocGroups,ou=Groups,o=Resources",
    # is_active defaults to True, allowing anyone from the uni to login
    "is_staff": (LDAPGroupQuery("cn=CUBRIC-All,ou=PSYCH,ou=AdHocGroups,ou=Groups,o=Resources")
    | LDAPGroupQuery("cn=Research_Staff_EVERY,ou=UniversityWide,ou=DA,ou=Groups,ou=Resources,o=CF")
    | LDAPGroupQuery("cn=PSYCH-AllPostgraduates,ou=PSYCH,ou=DAGroups,ou=Groups,o=Resources")
),
    "is_superuser": "cn=CUBRIC-Sudo,ou=PSYCH,ou=AdHocGroups,ou=Groups,o=Resources",
}

# This is the default, but I like to be explicit.
AUTH_LDAP_ALWAYS_UPDATE_USER = True

# Use LDAP group membership to calculate group permissions.
# AUTH_LDAP_FIND_GROUP_PERMS = True

# Cache distinguished names and group memberships for 30 mins to minimize
# LDAP traffic.
AUTH_LDAP_CACHE_TIMEOUT = 1800

# Keep ModelBackend around for per-user permissions and maybe a local
# superuser.
AUTHENTICATION_BACKENDS = (
    "django_auth_ldap.backend.LDAPBackend",
    "django.contrib.auth.backends.ModelBackend",
)

AUTH_LDAP_CACHE_GROUPS = True
```

## Accessing the model in HTML templates
You can make use of `jinja` within HTML templates, allowing you to display content based on conditions. The example below assumes you use Bootstrap along with `crispy-bootstrap5` and `django-crispy-forms`. These tools make working with forms much easier and take care of the CSS (headaches) etc.
```
{% load crispy_forms_tags %}

{% if user.is_authenticated %}
    <h4>Hello {{ user.first_name }} {{ user.last_name }}. You are already logged in.</h4>
    <br>
    <a class="btn btn-outline-secondary" href="{% url 'logout' %}" role="button">Logout</a>
{% else %}
    <form method="post">
    {% csrf_token %}
    {{ form|crispy }}
    <button type="submit" class="btn btn-outline-secondary">Login</button>
    </form>
```

## Adding users (using code)
It's also possible to add a user to Django from LDAP, without them logging in. This can be easily run from Jupyter (Django `shell_plus`). This is handy when you want to add them to a Django group or check their LDAP attributes before they access your site.
```
from django_auth_ldap.backend import LDAPBackend
import pprint

user = LDAPBackend().populate_user("sapgh3")
if user is None:
    raise Exception(No user found in LDAP with that username")

pprint(user.ldap_user.attrs)
```

## Advanced configuration

In CUBRIC's configuration, we have extended Django built-in User model and created a Profile model (within the 'Accounts' app). This allows us to pull **additional fields from LDAP** into the model. For example:

Create the 'accounts' app
```
django-admin createapp accounts
```

models.py
```
from django.contrib.auth.models import AbstractUser
from django.db.models import BooleanField, CharField, IntegerField
from django_extensions.db.fields import AutoSlugField
from django.urls import reverse


class Profile(AbstractUser):
    """User Profile Data - Extends built-in User model"""

    slug = AutoSlugField(max_length=10, populate_from=["username"])
    school = CharField(max_length=5, null=True, blank=True)
    userType = CharField(max_length=3, null=True, blank=True)
    uidNumber = IntegerField(null=True, blank=True)
    gidNumber = IntegerField(null=True, blank=True)
    telephoneNumber = CharField(max_length=31, null=True, blank=True)

    class Meta:
        ordering = ["last_name"]

    def __str__(self):
        return self.first_name + " " + self.last_name

    def get_absolute_url(self):
        """Return URL to detail page of Profile"""
        return reverse("profile_detail", kwargs={"slug": self.username})
```

settings.py
```
INSTALLED_APPS = [
    [...]
    'accounts',
    [...]
]

AUTH_USER_MODEL = "accounts.Profile"

AUTH_LDAP_USER_ATTR_MAP = {
    [...]
    "school": "CardiffJCCSTransDept",
    "userType": "CardiffJCCSTransType",
    "uidNumber": "uidNumber",
    "gidNumber": "gidNumber",
    "groupMembership": "groupMembership",
    "telephoneNumber": "telephoneNumber",
}
```

If you plan on implementing the same design, you must "start fresh" and migrate the new model (e.g. Profile) **before any other models**. This is very important so other models are using the correct one.
```
python manage.py makemigrations accounts
python manage.py migrate accounts
```

If you're using ``django-guardian`` to manage object-level permissions, update the following:
```
AUTHENTICATION_BACKENDS = (
    [...]
    "guardian.backends.ObjectPermissionBackend",
)
```