diff --git a/docker-compose.yml b/docker-compose.yml index ff3c3c4..a229d7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,9 @@ services: command: flask --app /app/lumi2 --debug run --host 0.0.0.0 --port 80 volumes: - ./lumi2/__init__.py:/app/lumi2/__init__.py:ro - - ./lumi2/usermanager.py:/app/lumi2/usermanager.py:ro + - ./lumi2/exceptions.py:/app/lumi2/exceptions.py:ro - ./lumi2/ldap.py:/app/lumi2/ldap.py:ro + - ./lumi2/usermanager.py:/app/lumi2/usermanager.py:ro - ./lumi2/static/:/app/lumi2/static/:ro - ./lumi2/templates/:/app/lumi2/templates/:ro ports: diff --git a/lumi2/__init__.py b/lumi2/__init__.py index 1937e73..5180a3c 100644 --- a/lumi2/__init__.py +++ b/lumi2/__init__.py @@ -19,9 +19,9 @@ def create_app(test_config=None): LDAP_HOSTNAME='ldap://openldap', LDAP_BIND_USER_DN='cn=admin,dc=example,dc=com', LDAP_BIND_USER_PASSWORD='admin', - LDAP_ROOT_DN='cn=example,cn=com', - LDAP_USER_PARENT_DN='ou=users,cn=example,cn=com', - LDAP_GROUPS_PARENT_DN='ou=groups,cn=example,cn=com', + LDAP_BASE_DN='cn=example,cn=com', + LDAP_USER_SEARCH_BASE='ou=users,cn=example,cn=com', + LDAP_GROUPS_SEARCH_BASE='ou=groups,cn=example,cn=com', LDAP_USER_OBJECT_CLASS='inetOrgPerson', LDAP_GROUP_OBJECT_CLASS='groupOfUniqueNames', ) diff --git a/lumi2/exceptions.py b/lumi2/exceptions.py new file mode 100644 index 0000000..0cc2746 --- /dev/null +++ b/lumi2/exceptions.py @@ -0,0 +1,6 @@ +"""Miscellaneous Exceptions and Errors.""" + +class MissingConfigKeyError(RuntimeError): + """Raised when an expected appconfig key-value pair is not found.""" + + pass diff --git a/lumi2/ldap.py b/lumi2/ldap.py index 2922a90..b3c612e 100644 --- a/lumi2/ldap.py +++ b/lumi2/ldap.py @@ -6,10 +6,289 @@ creating/reading/updating/deleting DIT entries. All function calls within this module rely heavily on the `ldap3 module `_. """ -from flask import current_app +from string import ascii_lowercase, ascii_uppercase, digits +from flask import current_app from ldap3 import Connection, Server, ALL +from exceptions import MissingConfigKeyError + +class InvalidStringFormatException(Exception): + """Exception raised when an invalid string format is encountered.""" + + pass + +def _assert_is_valid_base_dn(input_str) -> None: + """Checks whether the input string is a valid LDAP base DN. + + A valid base DN is a string of the format 'cn=,cn=', whereby: + (second level domain) is a substring containing only lowercase + alphanumeric characters with minimum length 1. + (top level domain) is a substring containing only lowercase latin + characters with minimum length 2. + + Parameters + ---------- + input_str : string + The input string to check. + + Returns + ------- + None + + Raises + ------ + TypeError + If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid LDAP base DN. + """ + + if not isinstance(input_str, str): + raise TypeError(f"Expected a string but got: {type(input_str)}.") + + tokens = input_str.split(',') + if len(tokens) != 2: + raise InvalidStringFormatException("Expected exactly one ',' character.") + for token in tokens: + if not token.startswith("cn="): + raise InvalidStringFormatException( + "Expected DN component to start with 'cn='." + ) + + token_sld, token_tld = tokens + if not len(token_sld): + raise InvalidStringFormatException( + "Expected at least one character in second level domain." + ) + if not len(token_tld) >= 2: + raise InvalidStringFormatException( + "Expected at least two characters in top level domain." + ) + + for char in token_sld: + if char not in ascii_lowercase + digits: + raise InvalidStringFormatException( + f"Expected only lowercase alphanumeric characters in second " \ + f"level domain but got: '{char}'." + ) + for char in token_tld: + if char not in ascii_lowercase: + raise InvalidStringFormatException( + f"Expected only lowercase ascii characters in top level " \ + f"domain but got: '{char}'." + ) + +def _assert_is_valid_search_base(input_str) -> None: + """Checks whether the input string is a valid LDAP search base string. + + A valid search base is a string of the format 'ou=,', whereby: + (name of organizational unit) is a substring containing only + alphanumeric characters with minimum length 1. + is a substring representing an LDAP base DN as expected by + _assert_is_valid_base_dn(). + + Parameters + ---------- + input_str : string + The input string to check. + + Returns + ------- + None + + Raises + ------ + TypeError + If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid LDAP search base string. + """ + + if not isinstance(input_str, str): + raise TypeError(f"Expected a string but got: {type(input_str)}.") + + tokens = input_str.split(',') + if len(tokens) != 3: + raise InvalidStringFormatException("Expected exactly two ',' characters.") + + if not tokens[0].startswith("ou="): + raise InvalidStringFormatException("Expected 'ou='.") + token_ou = tokens[0][:3] + token_base_dn = tokens[1] + "," + tokens[2] + + _assert_is_valid_base_dn(token_base_dn) + for char in token_ou: + if not char in ascii_lowercase + ascii_uppercase + digits: + raise InvalidStringFormatException( + f"Expected only alphanumeric characters in organizational unit " \ + f"but got: '{char}'." + ) + +def _assert_is_valid_user_object_class(input_str) -> None: + """Checks whether the input string is a valid LDAP user object class. + + The only valid user object class is currently 'inetOrgPerson'. + + Parameters + ---------- + input_str : string + The input string to check. + + Returns + ------- + None + + Raises + ------ + TypeError + If input_str is not of type string. + InvalidStringFormatException + If input_str is not 'inetOrgPerson'. + """ + + if not isinstance(input_str, str): + raise TypeError(f"Expected a string but got: {type(input_str)}.") + + if not input_str == 'inetOrgPerson': + raise InvalidStringFormatException( + f"Expected 'inetOrgPerson' but got: '{input_str}'." + ) + +def _assert_is_valid_group_object_class(input_str) -> None: + """Checks whether the input string is a valid LDAP group object class. + + The only valid group object class is currently 'groupOfUniqueNames'. + + Parameters + ---------- + input_str : string + The input string to check. + + Returns + ------- + None + + Raises + ------ + TypeError + If input_str is not of type string. + InvalidStringFormatException + If input_str is not 'groupOfUniqueNames'. + """ + + if not isinstance(input_str, str): + raise TypeError(f"Expected a string but got: {type(input_str)}.") + + if not input_str == 'groupOfUniqueNames': + raise InvalidStringFormatException( + f"Expected 'groupOfUniqueNames' but got: '{input_str}'." + ) + +def _assert_is_valid_bind_user_dn(input_str) -> None: + """Checks whether the input string is a valid LDAP bind user DN. + + A valid bind user DN is a string of the format 'cn=,', + whereby: + (name of bind user) is a substring containing only + alphanumeric characters with minimum length 1. + is a substring representing an LDAP base DN as expected by + _assert_is_valid_base_dn(). + + Parameters + ---------- + input_str : string + The input string to check. + + Returns + ------- + None + + Raises + ------ + TypeError + If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid bind user DN. + """ + + if not isinstance(input_str, str): + raise TypeError(f"Expected a string but got: {type(input_str)}.") + + tokens = input_str.split(',') + if len(tokens) != 3: + raise InvalidStringFormatException("Expected exactly two ',' characters.") + + if not tokens[0].startswith("cn="): + raise InvalidStringFormatException("Expected 'cn='.") + token_cn = tokens[0][:3] + token_base_dn = tokens[1] + "," + tokens[2] + + _assert_is_valid_base_dn(token_base_dn) + for char in token_cn: + if not char in ascii_lowercase + ascii_uppercase + digits: + raise InvalidStringFormatException( + f"Expected only alphanumeric characters in common name " \ + f"but got: '{char}'." + ) + +def _assert_app_config_is_valid() -> None: + """Checks the app config's LDAP settings for completeness and correctness. + + Ensures that the following config keys are present: + - 'LDAP_HOSTNAME' + - 'LDAP_BIND_USER_PASSWORD' + + Ensures that the following config keys are present and contain valid values: + - 'LDAP_BIND_USER_DN' + - 'LDAP_BASE_DN' + - 'LDAP_USER_SEARCH_BASE' + - 'LDAP_GROUPS_SEARCH_BASE' + - 'LDAP_USER_OBJECT_CLASS' + - 'LDAP_GROUP_OBJECT_CLASS' + + Returns + ------- + None + + Raises + ------ + TypeError + If any of the LDAP config values are not of type string. + MissingConfigKeyError + If any of the above-mentioned LDAP config keys are not declared. + InvalidStringFormatException + If any of the above-mentioned LDAP config values are in an invalid format. + """ + + required_keys = [ + 'LDAP_HOSTNAME', + 'LDAP_BIND_USER_DN', + 'LDAP_BIND_USER_PASSWORD', + 'LDAP_BASE_DN', + 'LDAP_USER_SEARCH_BASE', + 'LDAP_GROUPS_SEARCH_BASE', + 'LDAP_USER_OBJECT_CLASS', + 'LDAP_GROUP_OBJECT_CLASS', + ] + + for key in required_keys: + if key not in current_app.config: + raise MissingConfigKeyError( + f"App config: key '{key}' was not provided." + ) + if not isinstance(current_app.config[key], str): + raise TypeError( + f"Expected value of app config key '{key}' to be of type string." + ) + + _assert_is_valid_base_dn(current_app.config['LDAP_BASE_DN']) + _assert_is_valid_bind_user_dn(current_app.config['LDAP_BIND_USER_DN']) + for base in ['LDAP_USER_SEARCH_BASE', 'LDAP_GROUPS_SEARCH_BASE']: + _assert_is_valid_search_base(current_app.config[base]) + _assert_is_valid_user_object_class(current_app.config['LDAP_USER_OBJECT_CLASS']) + _assert_is_valid_group_object_class(current_app.config['LDAP_GROUP_OBJECT_CLASS']) + def get_authenticated_connection( hostname=current_app.config['LDAP_HOSTNAME'], user=current_app.config['LDAP_BIND_USER_DN'],