diff --git a/intranet/apps/auth/backends.py b/intranet/apps/auth/backends.py index 84eadd1767..f86cc9368e 100644 --- a/intranet/apps/auth/backends.py +++ b/intranet/apps/auth/backends.py @@ -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__) @@ -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.""" @@ -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 diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py index e67095be5b..655c300cec 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -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 = [ diff --git a/requirements.txt b/requirements.txt index d4a7918cae..188ad615fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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