feat(ldap): add app config validity assertions

This commit is contained in:
Julian Lobbes 2022-11-08 13:21:55 +01:00
parent 003345de5d
commit 8e478b7740
4 changed files with 291 additions and 5 deletions

View File

@ -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:

View File

@ -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',
)

6
lumi2/exceptions.py Normal file
View File

@ -0,0 +1,6 @@
"""Miscellaneous Exceptions and Errors."""
class MissingConfigKeyError(RuntimeError):
"""Raised when an expected appconfig key-value pair is not found."""
pass

View File

@ -6,10 +6,289 @@ creating/reading/updating/deleting DIT entries.
All function calls within this module rely heavily on the `ldap3 module <https://ldap3.readthedocs.io/en/latest/>`_.
"""
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=<SLD>,cn=<TLD>', whereby:
<SLD> (second level domain) is a substring containing only lowercase
alphanumeric characters with minimum length 1.
<TLD> (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=<NAME>,<BASE_DN>', whereby:
<NAME> (name of organizational unit) is a substring containing only
alphanumeric characters with minimum length 1.
<BASE_DN> 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=<USERNAME>,<BASE_DN>',
whereby:
<USERNAME> (name of bind user) is a substring containing only
alphanumeric characters with minimum length 1.
<BASE_DN> 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'],