lumi2/lumi2/usermodel.py

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