feat(ldap): add app config validity assertions
This commit is contained in:
parent
003345de5d
commit
8e478b7740
@ -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:
|
||||
|
@ -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
6
lumi2/exceptions.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Miscellaneous Exceptions and Errors."""
|
||||
|
||||
class MissingConfigKeyError(RuntimeError):
|
||||
"""Raised when an expected appconfig key-value pair is not found."""
|
||||
|
||||
pass
|
281
lumi2/ldap.py
281
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 <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'],
|
||||
|
Loading…
Reference in New Issue
Block a user