472 lines
14 KiB
Python
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
|