555 lines
18 KiB
Python
555 lines
18 KiB
Python
"""Provides the application-internal class-based models for users and groups."""
|
|
|
|
from string import ascii_lowercase, ascii_uppercase, digits, whitespace
|
|
from base64 import b64encode, b64decode
|
|
import hashlib
|
|
from binascii import Error as Base64DecodeError
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
from PIL.JpegImagePlugin import JpegImageFile
|
|
from flask import current_app
|
|
|
|
from lumi2.exceptions import InvalidStringFormatException, InvalidImageException
|
|
|
|
class User:
|
|
"""Class model for a user.
|
|
|
|
Attributes
|
|
----------
|
|
username : str
|
|
The user's username.
|
|
password_hash : str
|
|
Base64-encoded SHA512 hash of the user's password.
|
|
email : str
|
|
The user's email address.
|
|
first_name : str
|
|
The user's first name.
|
|
last_name : str
|
|
The user's last name.
|
|
display_name : str
|
|
The user's display name (as required by some LDAP-enabled applications).
|
|
picture : PIL.Image
|
|
The user's profile picture as a PIL Image object.
|
|
"""
|
|
|
|
|
|
@staticmethod
|
|
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,
|
|
numbers, hyphens, underscores and periods. A username must start with a
|
|
latin character.
|
|
Minimum length is 1, maximum length is 64 characters.
|
|
|
|
Parameters
|
|
----------
|
|
input_str : str
|
|
The string whose validity as a username to check.
|
|
|
|
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):
|
|
raise InvalidStringFormatException("Username must contain at least one character.")
|
|
if len(input_str) > 64:
|
|
raise InvalidStringFormatException("Username must not exceed 64 characters in length.")
|
|
|
|
if input_str[0] not in ascii_lowercase + ascii_uppercase:
|
|
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:
|
|
raise InvalidStringFormatException(
|
|
f"Invalid character in username: '{char}'."
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
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
|
|
text.
|
|
|
|
Parameters
|
|
----------
|
|
input_str : str
|
|
The string whose validity as a password hash to check.
|
|
|
|
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):
|
|
raise InvalidStringFormatException(
|
|
"Password hash must be at least one character in length."
|
|
)
|
|
|
|
try:
|
|
b64decode(input_str, validate=True)
|
|
except Base64DecodeError as e:
|
|
raise InvalidStringFormatException from e
|
|
|
|
|
|
@staticmethod
|
|
def assert_is_valid_email(input_str: str) -> None:
|
|
"""Checks whether the input string is a valid email address.
|
|
|
|
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.
|
|
|
|
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:
|
|
raise InvalidStringFormatException(
|
|
"Invalid email address: no '@' found."
|
|
)
|
|
if '.' not in input_str:
|
|
raise InvalidStringFormatException(
|
|
"Invalid email address: no top-level-domain found."
|
|
)
|
|
if '.' not in input_str.split('@')[1]:
|
|
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:
|
|
raise InvalidStringFormatException(
|
|
"Invalid email address: no whitespace permitted."
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
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, and 64 characters at most.
|
|
|
|
Parameters
|
|
----------
|
|
input_str : str
|
|
The string whose validity as a first-/last-/displayname to check.
|
|
|
|
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):
|
|
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:
|
|
raise InvalidStringFormatException(
|
|
"Invalid name: may not contain whitespace."
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
def assert_is_valid_picture(input_image: JpegImageFile) -> None:
|
|
"""Checks whether the input image is a valid Image object.
|
|
|
|
Valid images must be of type JpegImageFile.
|
|
|
|
Parameters
|
|
----------
|
|
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):
|
|
raise TypeError(f"Expected a PIL Image but got: '{type(input_image)}'.")
|
|
if not isinstance(input_image, JpegImageFile):
|
|
raise InvalidImageException(
|
|
"User picture must be in JPEG format."
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
def generate_password_hash(password: str) -> str:
|
|
"""Generates a base64-encoded SHA512 hash of the input string.
|
|
|
|
Parameters
|
|
----------
|
|
password : str
|
|
The plaintext password for which to generate a hash.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
A base64-encoded SHA512 hash digest of the input string.
|
|
|
|
Raises
|
|
------
|
|
TypeError
|
|
If the input is not of type string.
|
|
ValueError
|
|
If the input string is empty.
|
|
"""
|
|
|
|
if not isinstance(password, str):
|
|
raise TypeError(f"Expected a string but got: '{type(password)}'.")
|
|
if not len(password):
|
|
raise ValueError("Input string cannot be empty.")
|
|
|
|
hash_bytes = hashlib.sha512()
|
|
hash_bytes.update(bytes(password, "UTF-8"))
|
|
return b64encode(hash_bytes.digest()).decode("ASCII")
|
|
|
|
|
|
@staticmethod
|
|
def _get_default_picture() -> Image.Image:
|
|
"""Returns the default user picture as a PIL Image object.
|
|
|
|
Returns
|
|
-------
|
|
PIL.Image.Image
|
|
The default user profile picture.
|
|
"""
|
|
|
|
image_path = f"{current_app.static_folder}/images/default/user.jpg"
|
|
return Image.open(image_path)
|
|
|
|
|
|
def __init__(
|
|
self,
|
|
username: str, password_hash: str, email: str,
|
|
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
|
|
|
|
self.username = username
|
|
self.password_hash = password_hash
|
|
self.email = email
|
|
self.first_name = first_name
|
|
self.last_name = last_name
|
|
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:
|
|
"""Generates the static images for this User's picture on disc.
|
|
|
|
The user's full profile picture and a thumbnail are written to
|
|
'static/images/user/<username>/full.jpg'
|
|
and 'static/images/user/<username>/thumbnail.jpg' respectively.
|
|
The thumbnail's fixed size is 512x512 px.
|
|
|
|
If the parameter force is set to True, existing images are overwritten.
|
|
Otherwise, if the images already exist on disk, image generation is skipped.
|
|
|
|
Parameters
|
|
----------
|
|
force : bool = False
|
|
Whether or not existing images on disk should be regenerated.
|
|
"""
|
|
|
|
path_to_image_folder = Path(current_app.static_folder) / "images" / "users" / self.username
|
|
path_to_full_image = path_to_image_folder / "full.jpg"
|
|
path_to_thumbnail = path_to_image_folder / "thumbnail.jpg"
|
|
|
|
if not path_to_image_folder.is_dir():
|
|
path_to_image_folder.mkdir(parents=True)
|
|
|
|
if not path_to_full_image.is_file() or force:
|
|
self.picture.save(path_to_full_image)
|
|
|
|
if not path_to_thumbnail.is_file() or force:
|
|
thumb = self.picture.copy()
|
|
thumb.thumbnail((256, 256))
|
|
thumb.save(path_to_thumbnail)
|
|
|
|
|
|
def get_dn(self) -> str:
|
|
"""Returns the LDAP DN for the DIT entry representing this User.
|
|
|
|
The method does not check whether or not an entry with this DN actually
|
|
exists, it merely returns the DN as a string.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The LDAP DN (distinguished name) uniquely identifying this User in
|
|
the DIT.
|
|
"""
|
|
|
|
return "uid=" + self.username + ',' + current_app.config['LDAP_USERS_OU']
|
|
|
|
|
|
def get_picture_url(self):
|
|
"""Returns the URL to this user's static profile picture image file."""
|
|
|
|
return f'/static/images/users/{self.username}/full.jpg'
|
|
|
|
|
|
def get_thumbnail_url(self):
|
|
"""Returns the URL to this user's static profile thumbnail image file."""
|
|
|
|
return f'/static/images/users/{self.username}/thumbnail.jpg'
|
|
|
|
|
|
def __eq__(self, other):
|
|
return self.username == other.username
|
|
|
|
def __ne__(self, other):
|
|
return self.username != other.username
|
|
|
|
def __lt__(self, other):
|
|
return self.username < other.username
|
|
|
|
def __hash__(self):
|
|
return hash(self.username)
|
|
|
|
def __repr__(self):
|
|
repr_str = f'User("{self.username}", "{self.password_hash}", ' \
|
|
f'"{self.email}", "{self.first_name}", "{self.last_name}", ' \
|
|
f'"{self.display_name}", "{self.picture}")'
|
|
return repr_str
|
|
|
|
|
|
class Group:
|
|
"""Class model for a group of users.
|
|
|
|
Attributes
|
|
----------
|
|
groupname : str
|
|
The name (cn) of this group.
|
|
members : set[User]
|
|
The set of Users who are a member of this group.
|
|
"""
|
|
|
|
@staticmethod
|
|
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 alphabetic characters, has minimum
|
|
length 1 and maximum length 64.
|
|
|
|
Parameters
|
|
----------
|
|
input_str : str
|
|
The string to check for validity as a group name.
|
|
|
|
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):
|
|
raise InvalidStringFormatException(
|
|
"Invalid group name: must contain at least one character."
|
|
)
|
|
for char in input_str:
|
|
if not char in ascii_uppercase + ascii_lowercase:
|
|
raise InvalidStringFormatException(
|
|
f"Invalid character in group name: '{char}'."
|
|
)
|
|
|
|
|
|
def __init__(self, groupname: str, members: set[User]):
|
|
"""Constructor for Group objects.
|
|
|
|
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):
|
|
raise TypeError(f"Expected a set but got: '{type(members)}'.")
|
|
for member in members:
|
|
if not isinstance(member, User):
|
|
raise TypeError(f"Expected only Users in members set but got: '{type(member)}'.")
|
|
if not len(members):
|
|
raise ValueError("Expected at least one group member.")
|
|
self.members = members
|
|
|
|
|
|
def get_dn(self) -> str:
|
|
"""Returns the LDAP DN for the DIT entry representing this Group.
|
|
|
|
The method does not check whether or not an entry with this DN actually
|
|
exists, it merely returns the DN as a string.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The LDAP DN (distinguished name) uniquely identifying this Group in
|
|
the DIT.
|
|
"""
|
|
|
|
return "cn=" + self.groupname + ',' + current_app.config['LDAP_GROUPS_OU']
|
|
|
|
|
|
def __eq__(self, other):
|
|
return self.groupname == other.groupname
|
|
|
|
def __ne__(self, other):
|
|
return self.groupname != other.groupname
|
|
|
|
def __lt__(self, other):
|
|
return self.groupname < other.groupname
|
|
|
|
def __hash__(self):
|
|
return hash(self.groupname)
|
|
|
|
def __repr__(self):
|
|
repr_str = f'Group("{self.groupname}", "{self.members}")'
|
|
return repr_str
|