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."""
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 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.

View File

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