diff --git a/etebase_server/myauth/ldap.py b/etebase_server/myauth/ldap.py new file mode 100644 index 0000000..a0912d5 --- /dev/null +++ b/etebase_server/myauth/ldap.py @@ -0,0 +1,109 @@ +import logging + +from django.utils import timezone +from django.conf import settings +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from etebase_server.django.utils import CallbackContext +from etebase_server.myauth.models import get_typed_user_model, UserType +from etebase_server.fastapi.dependencies import get_authenticated_user +from etebase_server.fastapi.exceptions import PermissionDenied as FastAPIPermissionDenied +from fastapi import Depends + +import ldap + +User = get_typed_user_model() + + +def ldap_setting(name, default): + """Wrapper around django.conf.settings""" + return getattr(settings, f"LDAP_{name}", default) + + +class LDAPConnection: + __instance__ = None + __user_cache = {} # Username -> Valid until + + @staticmethod + def get_instance(): + """To get a Singleton""" + if not LDAPConnection.__instance__: + return LDAPConnection() + else: + return LDAPConnection.__instance__ + + def __init__(self): + # Cache some settings + self.__LDAP_FILTER = ldap_setting("FILTER", "") + self.__LDAP_SEARCH_BASE = ldap_setting("SEARCH_BASE", "") + + # The time a cache entry is valid (in hours) + try: + self.__LDAP_CACHE_TTL = int(ldap_setting("CACHE_TTL", "")) + except ValueError: + logging.error("Invalid value for cache_ttl. Defaulting to 1 hour") + self.__LDAP_CACHE_TTL = 1 + + password = ldap_setting("BIND_PW", "") + if not password: + pw_file = ldap_setting("BIND_PW_FILE", "") + if pw_file: + with open(pw_file, "r") as f: + password = f.read().replace("\n", "") + + self.__ldap_connection = ldap.initialize(ldap_setting("SERVER", "")) + try: + self.__ldap_connection.simple_bind_s(ldap_setting("BIND_DN", ""), password) + except ldap.LDAPError as err: + logging.error(f"LDAP Error occuring during bind: {err.desc}") + + def __is_cache_valid(self, username): + """Returns True if the cache entry is still valid. Returns False otherwise.""" + if username in self.__user_cache: + if timezone.now() <= self.__user_cache[username]: + # Cache entry is still valid + return True + return False + + def __remove_cache(self, username): + del self.__user_cache[username] + + def has_user(self, username): + """ + Since we don't care about the password and so authentication + another way, all we care about is whether the user exists. + """ + if self.__is_cache_valid(username): + return True + if username in self.__user_cache: + self.__remove_cache(username) + + filterstr = self.__LDAP_FILTER.replace("%s", username) + try: + result = self.__ldap_connection.search_s(self.__LDAP_SEARCH_BASE, ldap.SCOPE_SUBTREE, filterstr=filterstr) + except ldap.NO_RESULTS_RETURNED: + # We handle the specific error first and the the generic error, as + # we may expect ldap.NO_RESULTS_RETURNED, but not any other error + return False + except ldap.LDAPError as err: + logging.error(f"Error occured while performing an LDAP query: {err.desc}") + return False + + if len(result) == 1: + self.__user_cache[username] = timezone.now() + timezone.timedelta(hours=self.__LDAP_CACHE_TTL) + return True + return False + + +def is_user_in_ldap(user: UserType = Depends(get_authenticated_user)): + if not LDAPConnection.get_instance().has_user(user.username): + raise FastAPIPermissionDenied(detail="User not in LDAP directory.") + + +def create_user(context: CallbackContext, *args, **kwargs): + """ + A create_user function which first checks if the user already exists in the + configured LDAP directory. + """ + if not LDAPConnection.get_instance().has_user(kwargs["username"]): + raise DjangoPermissionDenied("User not in the LDAP directory.") + return User.objects.create_user(*args, **kwargs) diff --git a/etebase_server/settings.py b/etebase_server/settings.py index 98a6dcf..97ec7e9 100644 --- a/etebase_server/settings.py +++ b/etebase_server/settings.py @@ -140,6 +140,8 @@ config_locations = [ "/etc/etebase-server/etebase-server.ini", ] +ETEBASE_CREATE_USER_FUNC = "etebase_server.django.utils.create_user_blocked" + # Use config file if present if any(os.path.isfile(x) for x in config_locations): config = configparser.ConfigParser() @@ -168,7 +170,24 @@ if any(os.path.isfile(x) for x in config_locations): if "database-options" in config: DATABASES["default"]["OPTIONS"] = config["database-options"] -ETEBASE_CREATE_USER_FUNC = "etebase_server.django.utils.create_user_blocked" + if "ldap" in config: + ldap = config["ldap"] + LDAP_SERVER = ldap.get("server", "") + LDAP_SEARCH_BASE = ldap.get("search_base", "") + LDAP_FILTER = ldap.get("filter", "") + LDAP_BIND_DN = ldap.get("bind_dn", "") + LDAP_BIND_PW = ldap.get("bind_pw", "") + LDAP_BIND_PW_FILE = ldap.get("bind_pw_file", "") + LDAP_CACHE_TTL = ldap.get("cache_ttl", "") + + if not LDAP_BIND_DN: + raise Exception("LDAP enabled but bind_dn is not set!") + if not LDAP_BIND_PW and not LDAP_BIND_PW_FILE: + raise Exception("LDAP enabled but both bind_pw and bind_pw_file are not set!") + + # Configure EteBase to use LDAP + ETEBASE_CREATE_USER_FUNC = "etebase_server.myauth.ldap.create_user" + ETEBASE_API_PERMISSIONS_READ = ["etebase_server.myauth.ldap.is_user_in_ldap"] # Efficient file streaming (for large files) SENDFILE_BACKEND = "etebase_server.fastapi.sendfile.backends.simple"