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
|
command: flask --app /app/lumi2 --debug run --host 0.0.0.0 --port 80
|
||||||
volumes:
|
volumes:
|
||||||
- ./lumi2/__init__.py:/app/lumi2/__init__.py:ro
|
- ./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/ldap.py:/app/lumi2/ldap.py:ro
|
||||||
|
- ./lumi2/usermanager.py:/app/lumi2/usermanager.py:ro
|
||||||
- ./lumi2/static/:/app/lumi2/static/:ro
|
- ./lumi2/static/:/app/lumi2/static/:ro
|
||||||
- ./lumi2/templates/:/app/lumi2/templates/:ro
|
- ./lumi2/templates/:/app/lumi2/templates/:ro
|
||||||
ports:
|
ports:
|
||||||
|
@ -19,9 +19,9 @@ def create_app(test_config=None):
|
|||||||
LDAP_HOSTNAME='ldap://openldap',
|
LDAP_HOSTNAME='ldap://openldap',
|
||||||
LDAP_BIND_USER_DN='cn=admin,dc=example,dc=com',
|
LDAP_BIND_USER_DN='cn=admin,dc=example,dc=com',
|
||||||
LDAP_BIND_USER_PASSWORD='admin',
|
LDAP_BIND_USER_PASSWORD='admin',
|
||||||
LDAP_ROOT_DN='cn=example,cn=com',
|
LDAP_BASE_DN='cn=example,cn=com',
|
||||||
LDAP_USER_PARENT_DN='ou=users,cn=example,cn=com',
|
LDAP_USER_SEARCH_BASE='ou=users,cn=example,cn=com',
|
||||||
LDAP_GROUPS_PARENT_DN='ou=groups,cn=example,cn=com',
|
LDAP_GROUPS_SEARCH_BASE='ou=groups,cn=example,cn=com',
|
||||||
LDAP_USER_OBJECT_CLASS='inetOrgPerson',
|
LDAP_USER_OBJECT_CLASS='inetOrgPerson',
|
||||||
LDAP_GROUP_OBJECT_CLASS='groupOfUniqueNames',
|
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/>`_.
|
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 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(
|
def get_authenticated_connection(
|
||||||
hostname=current_app.config['LDAP_HOSTNAME'],
|
hostname=current_app.config['LDAP_HOSTNAME'],
|
||||||
user=current_app.config['LDAP_BIND_USER_DN'],
|
user=current_app.config['LDAP_BIND_USER_DN'],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user