diff --git a/authens/backends.py b/authens/backends.py index 4b489fa..28c8915 100644 --- a/authens/backends.py +++ b/authens/backends.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.db import transaction from authens.models import CASAccount, OldCASAccount -from authens.utils import get_cas_client +from authens.utils import get_cas_client, parse_entrance_year UserModel = get_user_model() @@ -11,30 +11,6 @@ class ENSCASError(Exception): pass -def get_entrance_year(attributes): - """Infer the entrance year of a CAS account holder from her home directory.""" - - # The home directory of a user is of the form /users/YEAR/DEPARTMENT/CAS_LOGIN where - # YEAR is a 2-digit number representing the entrance year of the student. We get the - # entrance year from there. - - home_dir = attributes.get("homeDirectory") - if home_dir is None: - raise ENSCASError("Entrance year not available") - - dirs = home_dir.split("/") - if len(dirs) < 3 or not dirs[2].isdecimal(): - raise ENSCASError("Invalid homeDirectory: {}".format(home_dir)) - - # Expand the 2-digit entrance year into 4 digits. - # This will break in 2080. - year = int(dirs[2]) - if year >= 80: - return 1900 + year - else: - return 2000 + year - - class ENSCASBackend: """AuthENS CAS authentication backend. @@ -106,8 +82,10 @@ class ENSCASBackend: - If a matching CAS account exists, retrieve it. """ - entrance_year = get_entrance_year(attributes) - email = attributes.get("email", None) + email = attributes.get("email") + entrance_year = parse_entrance_year(attributes.get("homeDirectory")) + if entrance_year is None: + raise ENSCASError("Entrance year not available") with transaction.atomic(): try: @@ -142,7 +120,7 @@ class ENSCASBackend: class OldCASBackend: """Authentication backend for old CAS accounts. - Given a CAS login, an entrance year and a password, first finds the matching + Given a CAS login, an entrance year and a password, first finds the matching OldCASAccount instance (if it exists), then checks the given password with the user associated to this account. """ diff --git a/authens/conf.py b/authens/conf.py new file mode 100644 index 0000000..6f542d2 --- /dev/null +++ b/authens/conf.py @@ -0,0 +1,4 @@ +default_settings = { + "LDAP_SERVER_URL": "ldaps://ldap.spi.ens.fr:636", + # TODO: CAS_SERVER_URL +} diff --git a/authens/shortcuts.py b/authens/shortcuts.py new file mode 100644 index 0000000..1707fca --- /dev/null +++ b/authens/shortcuts.py @@ -0,0 +1,62 @@ +"""Helper functions to get CAS metadata and create CAS accounts.""" + +# TODO: make the python-ldap dependency optional +import ldap + +from django.conf import settings +from django.contrib.auth import get_user_model + +from authens.conf import default_settings +from authens.models import CASAccount, OldCASAccount +from authens.utils import parse_entrance_year + +User = get_user_model() + + +def _extract_ldap_info(entry, field): + dn, attrs = entry + return attrs[field][0].decode("utf-8") + + +def fetch_cas_account(cas_login): + """Issue an LDAP connection to retrieve metadata associated to a CAS account.""" + + # Don't trust the user! Only accept alphanumeric account names. + if not cas_login.isalnum(): + raise ValueError("Illegal CAS login: {}".format(cas_login)) + + ldap_url = getattr(settings, "LDAP_SERVER_URL", default_settings["LDAP_SERVER_URL"]) + ldap_obj = ldap.initialize(ldap_url) + res = ldap_obj.search_s( + "dc=spi,dc=ens,dc=fr", + ldap.SCOPE_SUBTREE, + "(uid={})".format(cas_login), + ["uid", "cn", "homeDirectory"], + ) + if not res: + return None + + (res,) = res + assert _extract_ldap_info(res, "uid") == cas_login + return { + "cn": _extract_ldap_info(res, "cn"), + "entrance_year": parse_entrance_year(_extract_ldap_info(res, "homeDirectory")), + } + + +def register_cas_account(user: User, cas_login: str) -> CASAccount: + """Register a user as a CAS user and return the newly created CASAccount.""" + + if not cas_login: + raise ValueError("cas_login must be non-empty") + if CASAccount.objects.filter(cas_login=cas_login).exists(): + raise ValueError("A CAS account named '{}' exists already".format(cas_login)) + if CASAccount.objects.filter(user=user).exists(): + raise ValueError("User '{}' already has a CAS account".format(user)) + if OldCASAccount.objects.filter(user=user).exists(): + raise ValueError("User '{}' has an old CAS account".format(user)) + + entrance_year = fetch_cas_account(cas_login)["entrance_year"] + return CASAccount.objects.create( + user=user, cas_login=cas_login, entrance_year=entrance_year + ) diff --git a/authens/utils.py b/authens/utils.py index a2a5499..a7cbbb6 100644 --- a/authens/utils.py +++ b/authens/utils.py @@ -1,3 +1,5 @@ +"""Internal utility functions used by authens.""" + from cas import CASClient from urllib.parse import urlunparse @@ -11,3 +13,26 @@ def get_cas_client(request): ), server_url="https://cas.eleves.ens.fr/", ) + + +def parse_entrance_year(home_dir): + """Infer the entrance year of a CAS account from their home directory.""" + + # The home directory of a user is of the form /users/YEAR/DEPARTMENT/CAS_LOGIN where + # YEAR is a 2-digit number representing the entrance year of the student. We get the + # entrance year from there. + + if home_dir is None: + return None + + dirs = home_dir.split("/") + if len(dirs) < 3 or not dirs[2].isdecimal() or dirs[1] != "users": + raise ValueError("Invalid home directory: {}".format(home_dir)) + + # Expand the 2-digit entrance year into 4 digits. + # This will break in 2080. + year = int(dirs[2]) + if year >= 80: + return 1900 + year + else: + return 2000 + year