lumi2/lumi2/usermodel.py

472 lines
14 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 flask import current_app
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 is_valid_username(input_str: str) -> bool:
"""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.
Returns
-------
bool
True if input_str is a valid username and False otherwise.
Raises
------
TypeError
If input_str is not of type string.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str):
return False
if len(input_str) > 64:
return False
if input_str[0] not in ascii_lowercase + ascii_uppercase:
return False
valid_chars = ascii_lowercase + ascii_uppercase + digits + "-_."
for char in input_str:
if char not in valid_chars:
return False
return True
@staticmethod
def is_valid_password_hash(input_str: str) -> bool:
"""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.
Returns
-------
bool
True if input_str is a valid password hash and False otherwise.
Raises
------
TypeError
If input_str is not of type string.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str):
return False
try:
b64decode(input_str, validate=True)
return True
except Base64DecodeError:
return False
@staticmethod
def is_valid_email(input_str: str) -> bool:
"""Checks whether the input string is a valid email address.
WARNING: this validation is very rudimentary. Proper validation requires
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 '@'.
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.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if '@' not in input_str:
return False
if '.' not in input_str:
return False
if '.' not in input_str.split('@')[1]:
return False
for char in input_str:
if char in whitespace:
return False
return True
@staticmethod
def is_valid_person_name(input_str: str) -> bool:
"""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.
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.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str):
return False
for char in input_str:
if char in whitespace:
return False
return True
@staticmethod
def is_valid_picture(input_image: Image.Image) -> bool:
"""Checks whether the input image is a valid Image object.
TBD - unsure which formats and filesizes to allow here.
Parameters
----------
input_image : PIL.Image
The Image whose validity to check.
Returns
-------
bool
True if input_image is a valid Image and False otherwise.
Raises
------
TypeError
If input_image is not of type PIL.Image.
"""
if not isinstance(input_image, 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
@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,
):
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()
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 __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 is_valid_groupname(input_str: str) -> bool:
"""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.
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.
"""
if not isinstance(input_str, str):
raise TypeError(f"Expected a string but got: '{type(input_str)}'.")
if not len(input_str):
return False
if input_str[0] not in ascii_lowercase + ascii_uppercase:
return False
for char in input_str:
if not char in ascii_uppercase + ascii_lowercase + digits:
return False
return True
def __init__(self, groupname: str, members: set[User]):
if not Group.is_valid_groupname(groupname):
raise ValueError("Not a valid group name: '{groupname}'.")
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