diff --git a/lumi2/exceptions.py b/lumi2/exceptions.py index 0cc2746..d0892c2 100644 --- a/lumi2/exceptions.py +++ b/lumi2/exceptions.py @@ -4,3 +4,27 @@ class MissingConfigKeyError(RuntimeError): """Raised when an expected appconfig key-value pair is not found.""" 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 diff --git a/lumi2/ldap.py b/lumi2/ldap.py index a1fd919..820d21d 100644 --- a/lumi2/ldap.py +++ b/lumi2/ldap.py @@ -17,12 +17,7 @@ from ldap3 import Connection, Server, ALL, Reader, Writer, ObjectDef, MODIFY_REP from PIL import Image from lumi2.usermodel import User, Group -from lumi2.exceptions import MissingConfigKeyError - - -class InvalidStringFormatException(Exception): - """Exception raised when an invalid string format is encountered.""" - pass +from lumi2.exceptions import * class InvalidConnectionException(Exception): @@ -40,15 +35,6 @@ class EntryNotFoundException(Exception): 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: """Checks whether the input string is a valid LDAP base DN. diff --git a/lumi2/usermodel.py b/lumi2/usermodel.py index 07e9a2b..1ff2599 100644 --- a/lumi2/usermodel.py +++ b/lumi2/usermodel.py @@ -6,9 +6,11 @@ import hashlib from binascii import Error as Base64DecodeError from pathlib import Path -from PIL import Image +from PIL.Image import JpegImageFile, Image from flask import current_app +from lumi2.exceptions import InvalidStringFormatException, InvalidImageException + class User: """Class model for a user. @@ -32,7 +34,7 @@ class User: @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. Valid usernames can contain only uppercase/lowercase latin characters, @@ -45,38 +47,74 @@ class User: input_str : str The string whose validity as a username to check. - Returns - ------- - bool - True if input_str is a valid username and False otherwise. - Raises ------ TypeError If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid username. """ if not isinstance(input_str, str): raise TypeError(f"Expected a string but got: '{type(input_str)}'.") if not len(input_str): - return False + raise InvalidStringFormatException("Username must contain at least one character.") 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: - return False + raise InvalidStringFormatException("Username must start with a letter.") valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_." for char in input_str: if char not in valid_chars: - return False - - return True + raise InvalidStringFormatException( + f"Invalid character in username: '{char}'." + ) @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. A valid password hash is a non-empty string containing base64-decodeable @@ -87,136 +125,143 @@ class User: input_str : str 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 ------ TypeError If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid password hash. """ if not isinstance(input_str, str): raise TypeError(f"Expected a string but got: '{type(input_str)}'.") if not len(input_str): - return False + raise InvalidStringFormatException( + "Password hash must be at least one character in length." + ) try: b64decode(input_str, validate=True) - return True - except Base64DecodeError: - return False + except Base64DecodeError as e: + raise InvalidStringFormatException from e @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. - 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 valid email address contains no whitespace, at least one '@' character, and a '.' character somewhere after the '@'. + The maximum length for a valid email address is 64 characters. Parameters ---------- input_str : str 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 ------ TypeError If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid email address. """ if not isinstance(input_str, str): raise TypeError(f"Expected a string but got: '{type(input_str)}'.") if '@' not in input_str: - return False + raise InvalidStringFormatException( + "Invalid email address: no '@' found." + ) if '.' not in input_str: - return False + raise InvalidStringFormatException( + "Invalid email address: no top-level-domain found." + ) 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: if char in whitespace: - return False - - return True + raise InvalidStringFormatException( + "Invalid email address: no whitespace permitted." + ) @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. Valid names cannot contain whitespace and must be at least one character - long. + long, and 64 characters at most. Parameters ---------- input_str : str 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 ------ TypeError 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): raise TypeError(f"Expected a string but got: '{type(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: if char in whitespace: - return False - - return True + raise InvalidStringFormatException( + "Invalid name: may not contain whitespace." + ) @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. - TBD - unsure which formats and filesizes to allow here. + Valid images must be of type JpegImageFile. Parameters ---------- - input_image : PIL.Image - The Image whose validity to check. - - Returns - ------- - bool - True if input_image is a valid Image and False otherwise. + input_image : PIL.Image.JpegImageFile + The Image object whose validity to check. Raises ------ TypeError 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)}'.") - - # TODO implement some integrity checks - # TODO implement some filesize restrictions - return True + if not isinstance(input_image, JpegImageFile): + raise InvalidImageException( + "User picture must be in JPEG format." + ) @staticmethod @@ -271,38 +316,54 @@ class User: first_name: str, last_name: str, display_name = 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 - - if not User.is_valid_password_hash(password_hash): - raise ValueError(f"Not a valid 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 - - 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.last_name = last_name - - if display_name is not None: - 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() + self.display_name = display_name if display_name is not None else first_name + self.picture = picture if picture is not None else User._get_default_picture() def _generate_static_images(self, force=False) -> None: @@ -385,48 +446,61 @@ class Group: """ @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. 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 ---------- input_str : str 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 ------ TypeError If input_str is not of type string. + InvalidStringFormatException + If input_str is not a valid group name. """ if not isinstance(input_str, str): raise TypeError(f"Expected a string but got: '{type(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: - return False - + raise InvalidStringFormatException( + "Invalid group name: must start with a letter." + ) for char in input_str: if not char in ascii_uppercase + ascii_lowercase + digits: - return False - - return True + raise InvalidStringFormatException( + f"Invalid character in group name: '{char}'." + ) def __init__(self, groupname: str, members: set[User]): + """Constructor for Group objects. - if not Group.is_valid_groupname(groupname): - raise ValueError("Not a valid group name: '{groupname}'.") + Groups must always have at least one member (an LDAP limitation). + + 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 if not isinstance(members, set):