Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions intranet/apps/auth/backends.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import enum
import logging

import ldap
import pam
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import check_password
from django_auth_ldap.backend import LDAPBackend
from prometheus_client import Counter, Summary

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -146,6 +148,114 @@ def get_user(self, user_id):
return None


class TJHSSTLDAPBackend(LDAPBackend):
"""LDAP authentication backend that creates users based on group membership."""

def authenticate_ldap_user(self, ldap_user, password):
"""Authenticate LDAP user and create Django user if authorized."""
# First check if user is in authorized group
if not self._check_group_membership(ldap_user):
logger.warning("LDAP user %s not in authorized group", ldap_user.dn)
return None

# Check if Django user exists
try:
user = get_user_model().objects.get(username__iexact=ldap_user.username)
logger.debug("Existing Django user found for LDAP user %s", ldap_user.username)
return user
except get_user_model().DoesNotExist:
# Create new user from LDAP data
return self._create_user_from_ldap(ldap_user)

def _check_group_membership(self, ldap_user):
"""Check if LDAP user is member of authorized group."""
target_group = "cn=people,cn=groups,cn=accounts,dc=tjhsst,dc=edu"

if hasattr(ldap_user, 'group_dns'):
return target_group in ldap_user.group_dns

# Fallback: check memberOf attribute
member_of = ldap_user.attrs.get('memberOf', [])
return target_group in member_of

def _create_user_from_ldap(self, ldap_user):
"""Create Django user from LDAP attributes."""
User = get_user_model()

# Extract user data from LDAP
username = ldap_user.username
first_name = ldap_user.attrs.get('givenName', [''])[0]
last_name = ldap_user.attrs.get('sn', [''])[0]
email = ldap_user.attrs.get('mail', [''])[0]

# Determine user type and graduation year - fail if invalid
user_info = self._determine_user_info(ldap_user)
if user_info is None:
logger.warning("Invalid gidNumber for LDAP user %s, authentication failed", username)
return None

user_type, graduation_year = user_info

# Create user
user = User.objects.create(
username=username,
first_name=first_name,
last_name=last_name,
email=email,
user_type=user_type,
graduation_year=graduation_year
)

logger.info("Created new Django user from LDAP: %s (type: %s, grad_year: %s)",
username, user_type, graduation_year)
return user

def _determine_user_info(self, ldap_user):
"""Determine user type and graduation year from LDAP attributes.

Returns:
tuple (user_type, graduation_year) if valid, None if invalid gidNumber
"""
# Get gidNumber - this is required and must be valid
gid_number = ldap_user.attrs.get('gidNumber', [None])[0]
if not gid_number:
logger.warning("No gidNumber found for LDAP user %s", ldap_user.dn)
return None

try:
gid_number = int(gid_number)
except (ValueError, TypeError):
logger.warning("Invalid gidNumber format for LDAP user %s: %s", ldap_user.dn, gid_number)
return None

# Determine user type from group membership
staff_group = "cn=staff,cn=groups,cn=accounts,dc=tjhsst,dc=edu"
student_group = "cn=students,cn=groups,cn=accounts,dc=tjhsst,dc=edu"

member_of = ldap_user.attrs.get('memberOf', [])
if hasattr(ldap_user, 'group_dns'):
groups = ldap_user.group_dns
else:
groups = member_of

# Validate gidNumber and determine user type
if staff_group in groups:
if gid_number == 1984:
return 'staff', None
else:
logger.warning("Staff member %s has invalid gidNumber: %d (expected 1984)", ldap_user.dn, gid_number)
return None
elif student_group in groups:
if 1985 <= gid_number <= 9999:
return 'student', gid_number
else:
logger.warning("Student %s has invalid gidNumber: %d (expected 1985-9999)", ldap_user.dn, gid_number)
return None
else:
logger.warning("LDAP user %s not in staff or students group", ldap_user.dn)
return None


class MasterPasswordAuthenticationBackend:
"""Authenticate as any user against a master password whose hash is in secret.py."""

Expand Down Expand Up @@ -193,3 +303,111 @@ def get_user(self, user_id):
return get_user_model().objects.get(id=user_id)
except get_user_model().DoesNotExist:
return None


class TJHSSTLDAPBackend(LDAPBackend):
"""LDAP authentication backend that creates users based on group membership."""

def authenticate_ldap_user(self, ldap_user, password):
"""Authenticate LDAP user and create Django user if authorized."""
# First check if user is in authorized group
if not self._check_group_membership(ldap_user):
logger.warning("LDAP user %s not in authorized group", ldap_user.dn)
return None

# Check if Django user exists
try:
user = get_user_model().objects.get(username__iexact=ldap_user.username)
logger.debug("Existing Django user found for LDAP user %s", ldap_user.username)
return user
except get_user_model().DoesNotExist:
# Create new user from LDAP data
return self._create_user_from_ldap(ldap_user)

def _check_group_membership(self, ldap_user):
"""Check if LDAP user is member of authorized group."""
target_group = "cn=people,cn=groups,cn=accounts,dc=tjhsst,dc=edu"

if hasattr(ldap_user, 'group_dns'):
return target_group in ldap_user.group_dns

# Fallback: check memberOf attribute
member_of = ldap_user.attrs.get('memberOf', [])
return target_group in member_of

def _create_user_from_ldap(self, ldap_user):
"""Create Django user from LDAP attributes."""
User = get_user_model()

# Extract user data from LDAP
username = ldap_user.username
first_name = ldap_user.attrs.get('givenName', [''])[0]
last_name = ldap_user.attrs.get('sn', [''])[0]
email = ldap_user.attrs.get('mail', [''])[0]

# Determine user type and graduation year - fail if invalid
user_info = self._determine_user_info(ldap_user)
if user_info is None:
logger.warning("Invalid gidNumber for LDAP user %s, authentication failed", username)
return None

user_type, graduation_year = user_info

# Create user
user = User.objects.create(
username=username,
first_name=first_name,
last_name=last_name,
email=email,
user_type=user_type,
graduation_year=graduation_year
)

logger.info("Created new Django user from LDAP: %s (type: %s, grad_year: %s)",
username, user_type, graduation_year)
return user

def _determine_user_info(self, ldap_user):
"""Determine user type and graduation year from LDAP attributes.

Returns:
tuple (user_type, graduation_year) if valid, None if invalid gidNumber
"""
# Get gidNumber - this is required and must be valid
gid_number = ldap_user.attrs.get('gidNumber', [None])[0]
if not gid_number:
logger.warning("No gidNumber found for LDAP user %s", ldap_user.dn)
return None

try:
gid_number = int(gid_number)
except (ValueError, TypeError):
logger.warning("Invalid gidNumber format for LDAP user %s: %s", ldap_user.dn, gid_number)
return None

# Determine user type from group membership
staff_group = "cn=staff,cn=groups,cn=accounts,dc=tjhsst,dc=edu"
student_group = "cn=students,cn=groups,cn=accounts,dc=tjhsst,dc=edu"

member_of = ldap_user.attrs.get('memberOf', [])
if hasattr(ldap_user, 'group_dns'):
groups = ldap_user.group_dns
else:
groups = member_of

# Validate gidNumber and determine user type
if staff_group in groups:
if gid_number == 1984:
return 'staff', None
else:
logger.warning("Staff member %s has invalid gidNumber: %d (expected 1984)", ldap_user.dn, gid_number)
return None
elif student_group in groups:
if 1985 <= gid_number <= 9999:
return 'student', gid_number
else:
logger.warning("Student %s has invalid gidNumber: %d (expected 1985-9999)", ldap_user.dn, gid_number)
return None
else:
logger.warning("LDAP user %s not in staff or students group", ldap_user.dn)
return None
49 changes: 45 additions & 4 deletions intranet/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,15 +408,56 @@
PIPELINE["STYLESHEETS"].update(helpers.single_css_map(name))

AUTHENTICATION_BACKENDS = [
"intranet.apps.auth.backends.TJHSSTLDAPBackend",
"intranet.apps.auth.backends.MasterPasswordAuthenticationBackend",
"intranet.apps.auth.backends.PamAuthenticationBackend",
"oauth2_provider.backends.OAuth2Backend",
"django.contrib.auth.backends.ModelBackend",
]

# The Alpine dev env doesn't work well with PAM
if not PRODUCTION:
AUTHENTICATION_BACKENDS.remove("intranet.apps.auth.backends.PamAuthenticationBackend")
# LDAP Configuration
import ldap
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

# Primary server with fallback
AUTH_LDAP_SERVER_URI = "ldaps://ipa1.tjhsst.edu:636 ldaps://ipa2.tjhsst.edu:636"
AUTH_LDAP_START_TLS = False
AUTH_LDAP_BIND_DN = "" # Anonymous bind
AUTH_LDAP_BIND_PASSWORD = ""

# Connection options for failover
AUTH_LDAP_CONNECTION_OPTIONS = {
ldap.OPT_REFERRALS: 0,
ldap.OPT_NETWORK_TIMEOUT: 10, # 10 second timeout
}

# User search configuration
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"dc=tjhsst,dc=edu",
ldap.SCOPE_SUBTREE,
"(uid=%(user)s)"
)

# Group search configuration
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"cn=groups,cn=accounts,dc=tjhsst,dc=edu",
ldap.SCOPE_SUBTREE,
"(objectClass=groupOfNames)"
)
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn")

# Require group membership
AUTH_LDAP_REQUIRE_GROUP = "cn=people,cn=groups,cn=accounts,dc=tjhsst,dc=edu"

# User attribute mapping
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
"email": "mail",
}

# Cache groups to avoid repeated LDAP queries
AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 # 1 hour

# Default to Argon2, see https://docs.djangoproject.com/en/dev/topics/auth/passwords/#argon2-usage
PASSWORD_HASHERS = [
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pyrankvote==2.0.5
pysftp==0.2.9
python-magic==0.4.27
python-pam==2.0.2
python-ldap==3.4.4
django-auth-ldap==5.0.0
requests-oauthlib==2.0.0
sentry-sdk==2.32.0
service-identity==24.2.0
Expand Down
Loading