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'],