refactor(usermodel): turn validation methods into assertions

This commit is contained in:
Julian Lobbes 2022-11-17 15:57:35 +01:00
parent 7b1196f09d
commit 001e80977e
3 changed files with 202 additions and 118 deletions

View File

@ -4,3 +4,27 @@ class MissingConfigKeyError(RuntimeError):
"""Raised when an expected appconfig key-value pair is not found.""" """Raised when an expected appconfig key-value pair is not found."""
pass pass
class InvalidStringFormatException(Exception):
"""Exception raised when an invalid string format is encountered."""
pass
class InvalidImageException(Exception):
"""Exception raised when an invalid image is encountered."""
pass
class AttributeNotFoundException(Exception):
"""Exception raised when an entry's is unexpectedly not set."""
pass
class InvalidAttributeException(Exception):
"""Exception raised when an entry's is unexpectedly not set."""
pass

View File

@ -17,12 +17,7 @@ from ldap3 import Connection, Server, ALL, Reader, Writer, ObjectDef, MODIFY_REP
from PIL import Image from PIL import Image
from lumi2.usermodel import User, Group from lumi2.usermodel import User, Group
from lumi2.exceptions import MissingConfigKeyError from lumi2.exceptions import *
class InvalidStringFormatException(Exception):
"""Exception raised when an invalid string format is encountered."""
pass
class InvalidConnectionException(Exception): class InvalidConnectionException(Exception):
@ -40,15 +35,6 @@ class EntryNotFoundException(Exception):
pass pass
class AttributeNotFoundException(Exception):
"""Exception raised when an entry's is unexpectedly not set."""
pass
class InvalidAttributeException(Exception):
"""Exception raised when an entry's is unexpectedly not set."""
pass
def _assert_is_valid_base_dn(input_str) -> None: def _assert_is_valid_base_dn(input_str) -> None:
"""Checks whether the input string is a valid LDAP base DN. """Checks whether the input string is a valid LDAP base DN.

View File

@ -6,9 +6,11 @@ import hashlib
from binascii import Error as Base64DecodeError from binascii import Error as Base64DecodeError
from pathlib import Path from pathlib import Path
from PIL import Image from PIL.Image import JpegImageFile, Image
from flask import current_app from flask import current_app
from lumi2.exceptions import InvalidStringFormatException, InvalidImageException
class User: class User:
"""Class model for a user. """Class model for a user.
@ -32,7 +34,7 @@ class User:
@staticmethod @staticmethod
def is_valid_username(input_str: str) -> bool: def assert_is_valid_username(input_str: str) -> None:
"""Checks whether the input string is a valid username. """Checks whether the input string is a valid username.
Valid usernames can contain only uppercase/lowercase latin characters, Valid usernames can contain only uppercase/lowercase latin characters,
@ -45,38 +47,74 @@ class User:
input_str : str input_str : str
The string whose validity as a username to check. The string whose validity as a username to check.
Returns
-------
bool
True if input_str is a valid username and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_str is not of type string. If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid username.
""" """
if not isinstance(input_str, str): if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.") raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str): if not len(input_str):
return False raise InvalidStringFormatException("Username must contain at least one character.")
if len(input_str) > 64: if len(input_str) > 64:
return False raise InvalidStringFormatException("Username must not exceed 64 characters in length.")
if input_str[0] not in ascii_lowercase + ascii_uppercase: if input_str[0] not in ascii_lowercase + ascii_uppercase:
return False raise InvalidStringFormatException("Username must start with a letter.")
valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_." valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
for char in input_str: for char in input_str:
if char not in valid_chars: if char not in valid_chars:
return False raise InvalidStringFormatException(
f"Invalid character in username: '{char}'."
return True )
@staticmethod @staticmethod
def is_valid_password_hash(input_str: str) -> bool: def assert_is_valid_password(input_str: str) -> None:
"""Checks whether the input string is a valid password.
A valid password may not contain any whitespace.
They must be at least 8 characters long and at 64 characters at most.
Parameters
----------
input_str : str
The string whose validity as a password to check.
Raises
------
TypeError
If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid password.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if len(input_str) < 8:
raise InvalidStringFormatException(
"Password must be at least 8 characters in length."
)
if len(input_str) > 64:
raise InvalidStringFormatException(
"Password may not be longer than 8 characters."
)
for char in input_str:
if char in whitespace:
raise InvalidStringFormatException(
"Password my not contain any whitespace."
)
@staticmethod
def assert_is_valid_password_hash(input_str: str) -> None:
"""Checks whether the input string is a valid password hash. """Checks whether the input string is a valid password hash.
A valid password hash is a non-empty string containing base64-decodeable A valid password hash is a non-empty string containing base64-decodeable
@ -87,136 +125,143 @@ class User:
input_str : str input_str : str
The string whose validity as a password hash to check. The string whose validity as a password hash to check.
Returns
-------
bool
True if input_str is a valid password hash and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_str is not of type string. If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid password hash.
""" """
if not isinstance(input_str, str): if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.") raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str): if not len(input_str):
return False raise InvalidStringFormatException(
"Password hash must be at least one character in length."
)
try: try:
b64decode(input_str, validate=True) b64decode(input_str, validate=True)
return True except Base64DecodeError as e:
except Base64DecodeError: raise InvalidStringFormatException from e
return False
@staticmethod @staticmethod
def is_valid_email(input_str: str) -> bool: def assert_is_valid_email(input_str: str) -> None:
"""Checks whether the input string is a valid email address. """Checks whether the input string is a valid email address.
WARNING: this validation is very rudimentary. Proper validation requires Very rudimentary check, proper validation would require
a validation email to be sent and confirmed by the user. a validation email to be sent and confirmed by the user.
A valid email address contains no whitespace, at least one '@' character, A valid email address contains no whitespace, at least one '@' character,
and a '.' character somewhere after the '@'. and a '.' character somewhere after the '@'.
The maximum length for a valid email address is 64 characters.
Parameters Parameters
---------- ----------
input_str : str input_str : str
The string whose validity as an email address to check. The string whose validity as an email address to check.
Returns
-------
bool
True if input_str is a valid email address and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_str is not of type string. If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid email address.
""" """
if not isinstance(input_str, str): if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.") raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if '@' not in input_str: if '@' not in input_str:
return False raise InvalidStringFormatException(
"Invalid email address: no '@' found."
)
if '.' not in input_str: if '.' not in input_str:
return False raise InvalidStringFormatException(
"Invalid email address: no top-level-domain found."
)
if '.' not in input_str.split('@')[1]: if '.' not in input_str.split('@')[1]:
return False raise InvalidStringFormatException(
"Invalid email address: no top-level-domain found."
)
if len(input_str) > 64:
raise InvalidStringFormatException(
"Invalid email address: may not be longer than 64 characters."
)
for char in input_str: for char in input_str:
if char in whitespace: if char in whitespace:
return False raise InvalidStringFormatException(
"Invalid email address: no whitespace permitted."
return True )
@staticmethod @staticmethod
def is_valid_person_name(input_str: str) -> bool: def assert_is_valid_name(input_str: str) -> None:
"""Checks whether the input string is valid as a first/last/display name. """Checks whether the input string is valid as a first/last/display name.
Valid names cannot contain whitespace and must be at least one character Valid names cannot contain whitespace and must be at least one character
long. long, and 64 characters at most.
Parameters Parameters
---------- ----------
input_str : str input_str : str
The string whose validity as a first-/last-/displayname to check. The string whose validity as a first-/last-/displayname to check.
Returns
-------
bool
True if input_str is a valid name and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_str is not of type string. If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid person's name.
""" """
if not isinstance(input_str, str): if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.") raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str): if not len(input_str):
return False raise InvalidStringFormatException(
"Invalid name: must be at least 1 character in length."
)
if len(input_str) > 64:
raise InvalidStringFormatException(
"Invalid name: may not be longer than 64 characters."
)
for char in input_str: for char in input_str:
if char in whitespace: if char in whitespace:
return False raise InvalidStringFormatException(
"Invalid name: may not contain whitespace."
return True )
@staticmethod @staticmethod
def is_valid_picture(input_image: Image.Image) -> bool: def assert_is_valid_picture(input_image: JpegImageFile) -> None:
"""Checks whether the input image is a valid Image object. """Checks whether the input image is a valid Image object.
TBD - unsure which formats and filesizes to allow here. Valid images must be of type JpegImageFile.
Parameters Parameters
---------- ----------
input_image : PIL.Image input_image : PIL.Image.JpegImageFile
The Image whose validity to check. The Image object whose validity to check.
Returns
-------
bool
True if input_image is a valid Image and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_image is not of type PIL.Image. If input_image is not of type PIL.Image.
InvalidImageException
If the input image's type is not PIL.Image.JpegImageFile.
""" """
if not isinstance(input_image, Image.Image): if not isinstance(input_image, Image):
raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.") raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
if not isinstance(input_image, JpegImageFile):
# TODO implement some integrity checks raise InvalidImageException(
# TODO implement some filesize restrictions "User picture must be in JPEG format."
return True )
@staticmethod @staticmethod
@ -271,38 +316,54 @@ class User:
first_name: str, last_name: str, display_name = None, first_name: str, last_name: str, display_name = None,
picture = None, picture = None,
): ):
"""Constructor for User objects.
Parameters
----------
username : str
The username, valid as described by User.assert_is_valid_username().
password_hash : str
The user's base64-encoded SHA512-hashed password (without the
'{SHA512}'-prefix expected by LDAP).
Must be valid as described by User.assert_is_valid_password_hash().
email : str
The User's email address.
Must be valid as described by User.assert_is_valid_email().
first_name : str
The User's first name.
Must be valid as described by User.assert_is_valid_name().
last_name : str
The User's last name.
Must be valid as described by User.assert_is_valid_name().
display_name : str = first_name
The User's nickname. If unspecified, gets set to the User's first
name.
Must be valid as described by User.assert_is_valid_name().
picture : PIL.Image.JpegImageFile
The User's JPEG picture. If unspecified, a default user picture is
used. Must be valid as described by User.asser_is_valid_picture().
"""
try:
User.assert_is_valid_username(username)
User.assert_is_valid_password_hash(password_hash)
User.assert_is_valid_email(email)
User.assert_is_valid_name(first_name)
User.assert_is_valid_name(last_name)
if display_name is not None:
User.assert_is_valid_name(display_name)
if picture is not None:
User.assert_is_valid_picture(picture)
except (InvalidStringFormatException, InvalidImageException) as e:
raise ValueError from e
if not User.is_valid_username(username):
raise ValueError(f"Not a valid username: '{username}'.")
self.username = username self.username = username
if not User.is_valid_password_hash(password_hash):
raise ValueError(f"Not a valid password hash: '{password_hash}'.")
self.password_hash = password_hash self.password_hash = password_hash
if not User.is_valid_email(email):
raise ValueError(f"Not a valid email address: '{email}'.")
self.email = email self.email = email
for name in [first_name, last_name]:
if not User.is_valid_person_name(name):
raise ValueError(f"Not a valid name: '{name}'.")
self.first_name = first_name self.first_name = first_name
self.last_name = last_name self.last_name = last_name
self.display_name = display_name if display_name is not None else first_name
if display_name is not None: self.picture = picture if picture is not None else User._get_default_picture()
if not User.is_valid_person_name(display_name):
raise ValueError(f"Not a valid display name: '{display_name}'.")
self.display_name = display_name
else:
self.display_name = first_name
if picture is not None:
if not User.is_valid_picture(picture):
raise ValueError(f"Not a valid image: '{picture}'.")
self.picture = picture
else:
self.picture = User._get_default_picture()
def _generate_static_images(self, force=False) -> None: def _generate_static_images(self, force=False) -> None:
@ -385,48 +446,61 @@ class Group:
""" """
@staticmethod @staticmethod
def is_valid_groupname(input_str: str) -> bool: def assert_is_valid_groupname(input_str: str) -> None:
"""Checks whether the input string is a valid group name. """Checks whether the input string is a valid group name.
A valid group name consists of only alphanumeric characters, starts with A valid group name consists of only alphanumeric characters, starts with
a latin character and has minimum length 1. a latin character, has minimum length 1 and maximim length 64.
Parameters Parameters
---------- ----------
input_str : str input_str : str
The string to check for validity as a group name. The string to check for validity as a group name.
Returns
-------
bool
True if input_str is a valid group name, and False otherwise.
Raises Raises
------ ------
TypeError TypeError
If input_str is not of type string. If input_str is not of type string.
InvalidStringFormatException
If input_str is not a valid group name.
""" """
if not isinstance(input_str, str): if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.") raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str): if not len(input_str):
return False raise InvalidStringFormatException(
"Invalid group name: must contain at least one character."
)
if input_str[0] not in ascii_lowercase + ascii_uppercase: if input_str[0] not in ascii_lowercase + ascii_uppercase:
return False raise InvalidStringFormatException(
"Invalid group name: must start with a letter."
)
for char in input_str: for char in input_str:
if not char in ascii_uppercase + ascii_lowercase + digits: if not char in ascii_uppercase + ascii_lowercase + digits:
return False raise InvalidStringFormatException(
f"Invalid character in group name: '{char}'."
return True )
def __init__(self, groupname: str, members: set[User]): def __init__(self, groupname: str, members: set[User]):
"""Constructor for Group objects.
if not Group.is_valid_groupname(groupname): Groups must always have at least one member (an LDAP limitation).
raise ValueError("Not a valid group name: '{groupname}'.")
Parameters
----------
groupname : str
The Group's name.
Must be valid as described by Group.assert_is_valid_groupname().
members : set[User]
A set conaining Users who are members of this Group.
The set must contain at least one member.
"""
try:
Group.assert_is_valid_groupname(groupname)
except InvalidStringFormatException as e:
raise ValueError from e
self.groupname = groupname self.groupname = groupname
if not isinstance(members, set): if not isinstance(members, set):