From 4693edd5e8f798ca0de547354ae100f271ff8b92 Mon Sep 17 00:00:00 2001 From: Peter Yefi Date: Thu, 8 Dec 2022 20:54:47 -0500 Subject: [PATCH] Added user authentication capabilities to hub --- DEPLOYMENT.md | 11 +- exports/user_factory.py | 31 ++++++ helpers/auth.py | 43 ++++++++ helpers/city_util.py | 122 ---------------------- imports/user_factory.py | 45 ++++++++ persistence/__init__.py | 3 +- persistence/db_setup.py | 22 +++- persistence/models/__init__.py | 2 +- persistence/models/building.py | 31 ------ persistence/models/user.py | 45 ++++++++ persistence/repositories/__init__.py | 2 +- persistence/repositories/building_repo.py | 92 ---------------- persistence/repositories/user_repo.py | 101 ++++++++++++++++++ requirements.txt | 1 + 14 files changed, 299 insertions(+), 252 deletions(-) create mode 100644 exports/user_factory.py create mode 100644 helpers/auth.py delete mode 100644 helpers/city_util.py create mode 100644 imports/user_factory.py delete mode 100644 persistence/models/building.py create mode 100644 persistence/models/user.py delete mode 100644 persistence/repositories/building_repo.py create mode 100644 persistence/repositories/user_repo.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 7963ac64..4613b954 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -46,16 +46,21 @@ from exports.db_factory import DBFactory from pathlib import Path dotenv_path = (Path(__file__).parent / '.env').resolve() -factory = DBFactory(db_name='hub_db', app_env='PROD', dotenv_path=dotenv_path, city=None) +factory = DBFactory(db_name='hub_db', app_env='PROD', dotenv_path=dotenv_path) ``` ## Create Database Tables ## -Use the *DBSetUp* class in the persistence package to create the required database tables as described below +Use the *DBSetup* class in the persistence package to create the required database tables as described below ```python from persistence import DBSetup from pathlib import Path dotenv_path = (Path(__file__).parent / '.env').resolve() DBSetup(db_name='hub_db', app_env='PROD', dotenv_path=dotenv_path) -``` \ No newline at end of file +``` +The *DBSetUp* class also creates a default admin user with default credentials that can be changed. +with the import UserFactory class. The admin user (name, email, password and role) is logged into the console after it is created by the +*constructor of DBSetup*. Use can also manage users (create, read, update and delete) with user import and export factories. + +**NB: Make sure to change the default admin user credentials** diff --git a/exports/user_factory.py b/exports/user_factory.py new file mode 100644 index 00000000..6ac06abc --- /dev/null +++ b/exports/user_factory.py @@ -0,0 +1,31 @@ +""" +User performs user related crud operations +SPDX - License - Identifier: LGPL - 3.0 - or -later +Copyright © 2022 Concordia CERC group +Project CoderPeter Yefi peteryefi@gmail.com +""" +from persistence import UserRepo + + +class UserFactory: + """ + UserFactory class + """ + + def __init__(self, db_name, app_env, dotenv_path): + self._user_repo = UserRepo(db_name=db_name, app_env=app_env, dotenv_path=dotenv_path) + + def login_user(self, email: str, password: str): + """ + Retrieve a single city from postgres + :param email: the email of the user + :param password: the password of the user + """ + return self._user_repo.get_user_by_email_and_password(email, password) + + def get_user_by_email(self, email): + """ + Retrieve a single user + :param email: the email of the user to get + """ + return self._user_repo.get_by_email(email) diff --git a/helpers/auth.py b/helpers/auth.py new file mode 100644 index 00000000..161a3ca2 --- /dev/null +++ b/helpers/auth.py @@ -0,0 +1,43 @@ +import bcrypt +import re + + +class Auth(object): + + @staticmethod + def validate_password(password: str) -> bool: + """ + Validates a password + :param password: the password to validate + :return: + """ + pattern = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!#%*?&]{6,20}$" + pattern = re.compile(pattern) + if not re.search(pattern, password): + raise ValueError("Password must be between 6 to 20 characters and must have at least a number, an uppercase " + "letter, a lowercase letter, and a special character") + return True + + @staticmethod + def hash_password(password: str) -> str: + """ + Hashes a password + :param password: the password to be hashed + :return: + """ + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(14)).decode('utf-8') + + @staticmethod + def check_password(password: str, hashed_password) -> bool: + """ + Hashes a password + :param password: the password to be checked + :param hashed_password: the hashed password + :return: + """ + return bcrypt.checkpw(password.encode('utf-8'), hashed_password.encode('utf-8')) + + + + + diff --git a/helpers/city_util.py b/helpers/city_util.py deleted file mode 100644 index 9c69f714..00000000 --- a/helpers/city_util.py +++ /dev/null @@ -1,122 +0,0 @@ -from persistence.models import Building -from city_model_structure.building_demand.surface import Surface -from typing import Dict, List, Union -import numpy as np - - -class CityUtil(object): - # holds a single instance of this class - _instance = None - - def __init__(self): - pass - - def __new__(cls): - """ - Implemented for a singleton pattern - """ - if cls._instance is None: - cls._instance = super(CityUtil, cls).__new__(cls) - return cls._instance - - def _class_object_to_dict(self, class_obj) -> Union[Dict, None]: - """ - converts a class object to a dictionary - :param class_obj: the class object - :return: - """ - if class_obj is None: - return None - if type(class_obj) is not dict: - return vars(class_obj) - return class_obj - - def _ndarray_to_list(self, key, building_dict) -> Union[Dict, None]: - """ - Converts a numpy array to dictionary - :param key: the key to the dictionary value - :param building_dict: the dictionary - :return: - """ - if dict is not None: - if type(building_dict[key]) is np.ndarray: - return building_dict[key].tolist() - else: - return building_dict[key] - return None - - def object_list_to_dict_list(self, object_list): - """ - converts a list of objects to a list of dictionaries - :param object_list: a list of objects - :return: - """ - if object_list is None: - return [] - return [self._class_object_to_dict(obj) for obj in object_list] - - def _serialize_points(self, points) -> List: - """ - Deserializes arry of Point objects to array of dictionarier - :param points: a Point object - :return a list of points []: - """ - if points is None: - return None - serialized_points = [] - for i in range(len(points)): - point = self._class_object_to_dict(points[i]) - point['_coordinates'] = self._ndarray_to_list('_coordinates', point) - serialized_points.append(point) - return serialized_points - - def extract_building_data(self, building: Building) -> Dict: - """ - Extracts various values from a building - :param building: the building object - :return: a dictionary {} of building attributes - """ - dict_building = {} - for key, value in vars(building).items(): - if key in ['_name', '_year_of_construction', '_function', '_floor_area']: - continue - if type(value) is list: - if len(value) == 0: - dict_building[key] = [] - elif len(value) > 0: - if type(value[0]) is Surface: - try: - surfaces = [] - for surface in value: - surface_dict = vars(surface) - perimeter_polygon = self._class_object_to_dict(surface_dict['_perimeter_polygon']) - solid_polygon = self._class_object_to_dict(surface_dict['_solid_polygon']) - holes_polygon = self._class_object_to_dict(surface_dict['_holes_polygons']) - - if perimeter_polygon is not None: - perimeter_polygon['_coordinates'] = self._ndarray_to_list('_coordinates', perimeter_polygon) - perimeter_polygon['_points'] = self._serialize_points(perimeter_polygon['_points']) - surface_dict['_perimeter_polygon'] = perimeter_polygon - - if holes_polygon is not None: - holes_polygon['_coordinates'] = self._ndarray_to_list('_coordinates', holes_polygon) - holes_polygon['_points'] = self._serialize_points(holes_polygon['_points']) - surface_dict['_holes_polygons'] = holes_polygon - - if solid_polygon is not None: - solid_polygon['_coordinates'] = self._ndarray_to_list('_coordinates', solid_polygon) - solid_polygon['_points'] = self._serialize_points(solid_polygon['_points']) - surface_dict['_solid_polygon'] = solid_polygon - - surfaces.append(surface_dict) - dict_building[key] = surfaces - except KeyError as err: - print(f'Dictionary key error: {err}') - elif value is None: - dict_building[key] = None - elif type(value) in [str, int, dict, np.float64]: - dict_building[key] = value - elif type(value) is np.ndarray: - dict_building[key] = value.tolist() - - return dict_building diff --git a/imports/user_factory.py b/imports/user_factory.py new file mode 100644 index 00000000..ebfd3ee2 --- /dev/null +++ b/imports/user_factory.py @@ -0,0 +1,45 @@ +""" +User performs user-related crud operations +SPDX - License - Identifier: LGPL - 3.0 - or -later +Copyright © 2022 Concordia CERC group +Project CoderPeter Yefi peteryefi@gmail.com +""" +from persistence import UserRepo +from persistence import UserRoles + + +class UserFactory: + """ + UserFactory class + """ + + def __init__(self, db_name, app_env, dotenv_path): + self._user_repo = UserRepo(db_name=db_name, app_env=app_env, dotenv_path=dotenv_path) + + def create_user(self, name: str, email: str, password: str, role: UserRoles): + """ + Creates a new user + :param name: the name of the user + :param email: the email of the user + :param password: the password of the user + :param role: the role of the user + """ + return self._user_repo.insert(name, email, password, role) + + def update_user(self, user_id: int, name: str, email: str, password: str, role: UserRoles): + """ + Creates a new user + :param user_id: the id of the user + :param name: the name of the user + :param email: the email of the user + :param password: the password of the user + :param role: the role of the user + """ + return self._user_repo.update(user_id, name, email, password, role) + + def delete_user(self, user_id): + """ + Retrieve a single user + :param user_id: the id of the user to delete + """ + return self._user_repo.delete_user(user_id) diff --git a/persistence/__init__.py b/persistence/__init__.py index c9d381ef..caebc883 100644 --- a/persistence/__init__.py +++ b/persistence/__init__.py @@ -1,5 +1,6 @@ from .base_repo import BaseRepo from .repositories.city_repo import CityRepo -from .repositories.building_repo import BuildingRepo from .repositories.heat_pump_simulation_repo import HeatPumpSimulationRepo from .db_setup import DBSetup +from .repositories.user_repo import UserRepo +from .models.user import UserRoles diff --git a/persistence/db_setup.py b/persistence/db_setup.py index aa04ca9d..fc3625d6 100644 --- a/persistence/db_setup.py +++ b/persistence/db_setup.py @@ -1,11 +1,31 @@ from persistence.models import City from persistence import BaseRepo from persistence.models import HeatPumpSimulation +from persistence.models import User +from persistence.repositories import UserRepo +from persistence.models import UserRoles class DBSetup: def __init__(self, db_name, app_env, dotenv_path): + """ + Creates database tables and a default admin user + :param db_name: + :param app_env: + :param dotenv_path: + """ repo = BaseRepo(db_name=db_name, app_env=app_env, dotenv_path=dotenv_path) City.__table__.create(bind=repo.engine, checkfirst=True) - HeatPumpSimulation.__table__.create(bind=repo.engine, checkfirst=True) \ No newline at end of file + HeatPumpSimulation.__table__.create(bind=repo.engine, checkfirst=True) + User.__table__.create(bind=repo.engine, checkfirst=True) + self._user_repo = UserRepo(db_name=db_name, app_env=app_env, dotenv_path=dotenv_path) + self._create_admin_user(self._user_repo) + + def _create_admin_user(self, user_repo): + email = 'admin@hub.com'git + password = 'HubAdmin#!98' + print('Creating default admin user...') + user_repo.insert('Administrator', email, password, UserRoles.Admin) + print(f'Created Admin user with email: {email}, password: {password} and role: {UserRoles.Admin}') + print('Remember to change the admin default password and email address with the UserFactory') diff --git a/persistence/models/__init__.py b/persistence/models/__init__.py index c3a2e4fd..d29ea9e3 100644 --- a/persistence/models/__init__.py +++ b/persistence/models/__init__.py @@ -1,5 +1,5 @@ -from .building import Building from .city import City from .heat_pump_simulation import HeatPumpSimulation from .heat_pump_simulation import SimulationTypes from .heat_pump_simulation import HeatPumpTypes +from .user import User, UserRoles diff --git a/persistence/models/building.py b/persistence/models/building.py deleted file mode 100644 index e2c1b145..00000000 --- a/persistence/models/building.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Model representation of a building -SPDX - License - Identifier: LGPL - 3.0 - or -later -Copyright © 2022 Concordia CERC group -Project Coder Peter Yefi peteryefi@gmail.com -""" - -from sqlalchemy import Column, Integer, String, Sequence, Float, ForeignKey -from sqlalchemy.dialects.postgresql import JSONB -from persistence.db_config import Base - - -class Building(Base): - """A model representation of a building - - Attributes: - city_id A reference to the city which has this building. - name The name of the building. - construction_year The year of construction of the building. - function The function (e.g. residential) of the building. - floor_area The computed area of the floor of the building. - data A JSON object which contain other data (like roof, walls, zones, etc) of the building - """ - __tablename__ = "building" - id = Column(Integer, Sequence('building_id_seq'), primary_key=True) - city_id = Column(Integer, ForeignKey('city.id'), nullable=False) - name = Column(String, nullable=False) - construction_year = Column(Integer, nullable=False) - function = Column(String, nullable=False) - floor_area = Column(Float, nullable=False) - data = Column(JSONB, nullable=False) diff --git a/persistence/models/user.py b/persistence/models/user.py new file mode 100644 index 00000000..c4543884 --- /dev/null +++ b/persistence/models/user.py @@ -0,0 +1,45 @@ +""" +Model representation of a User +SPDX - License - Identifier: LGPL - 3.0 - or -later +Copyright © 2022 Concordia CERC group +Project Coder Peter Yefi peteryefi@gmail.com +""" + +from sqlalchemy import Column, Integer, String, Sequence +from sqlalchemy import DateTime, Enum +from persistence.db_config import Base +import datetime +from sqlalchemy.orm import validates +import re +import enum + + +class UserRoles(enum.Enum): + Admin = 'ADMIN' + HubReader = 'HUB_READER' + + +class User(Base): + """A model representation of a city + """ + __tablename__ = "user" + id = Column(Integer, Sequence('user_id_seq'), primary_key=True) + name = Column(String, nullable=False) + email = Column(String, nullable=False, unique=True) + password = Column(String, nullable=False) + role = Column(Enum(UserRoles), nullable=False, default=UserRoles.HubReader) + created = Column(DateTime, default=datetime.datetime.utcnow) + updated = Column(DateTime, default=datetime.datetime.utcnow) + + @validates("email") + def validate_email(self, key, address): + pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + if not re.match(pattern, address): + raise ValueError("failed simple email validation") + return address + + def __init__(self, name, email, password, role): + self.name = name + self.email = email + self.password = password + self.role = role diff --git a/persistence/repositories/__init__.py b/persistence/repositories/__init__.py index 8b137891..febdaca3 100644 --- a/persistence/repositories/__init__.py +++ b/persistence/repositories/__init__.py @@ -1 +1 @@ - +from .user_repo import UserRepo diff --git a/persistence/repositories/building_repo.py b/persistence/repositories/building_repo.py deleted file mode 100644 index bd7bbfe2..00000000 --- a/persistence/repositories/building_repo.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Building repository with database CRUD operations -SPDX - License - Identifier: LGPL - 3.0 - or -later -Copyright © 2022 Concordia CERC group -Project Coder Peter Yefi peteryefi@gmail.com -""" - -from city_model_structure.building import Building as CityBuilding -from sqlalchemy.exc import SQLAlchemyError -from persistence.models import Building -from persistence import BaseRepo -from helpers.city_util import CityUtil -from sqlalchemy import select - - -class BuildingRepo(BaseRepo): - - def __init__(self, db_name): - super().__init__(db_name) - self._city_util = CityUtil() - - def insert(self, building: CityBuilding, city_id: int) -> Building: - """ - Inserts a new building into the database - :param building: the building to insert - :param city_id: the city the building belongs to - :return: - """ - - model_building = Building() - model_building.name = building.name - model_building.function = building.function - model_building.construction_year = building.year_of_construction - model_building.floor_area = building.floor_area - model_building.city_id = city_id - model_building.data = self._city_util.extract_building_data(building) - try: - self.session.add(model_building) - self.session.flush() - self.session.commit() - return model_building - except SQLAlchemyError as err: - print(f'Error while adding building: {err}') - - def get_by_id(self, building_id: int) -> Building: - """ - Fetch a building based on the id - :param building_id: the building id - :return: a Building - """ - try: - return self.session.execute(select(Building).where(Building.id == building_id)).first()[0] - except SQLAlchemyError as err: - print(f'Error while fetching building: {err}') - - def get_by_name(self, building_name: str) -> [Building]: - """ - Fetch a building based on the name - :param building_name: the name of the building - :return: [Building] with the provided name - """ - try: - result_set = self.session.execute(select(Building).where(Building.name == building_name)) - return [building[0] for building in result_set] - except SQLAlchemyError as err: - print(f'Error while fetching buildings: {err}') - - def get_by_construction_year(self, year: int): - """ - Fetch a building based on the year of construction - :param year: the construction year of the building - :return: [Building] - """ - try: - result_set = self.session.execute(select(Building).where(Building.construction_year == year)) - return [building[0] for building in result_set] - except SQLAlchemyError as err: - print(f'Error while fetching buildings: {err}') - - def get_by_city(self, city_id: int): - """ - Get all the buildings in a city - :param city_id: the city id - :return: [Building] - """ - try: - result_set = self.session.execute(select(Building).where(Building.city_id == city_id)) - return [building[0] for building in result_set] - except SQLAlchemyError as err: - print(f'Error while fetching buildings: {err}') - - diff --git a/persistence/repositories/user_repo.py b/persistence/repositories/user_repo.py new file mode 100644 index 00000000..ee576bff --- /dev/null +++ b/persistence/repositories/user_repo.py @@ -0,0 +1,101 @@ +""" +City repository with database CRUD operations +SPDX - License - Identifier: LGPL - 3.0 - or -later +Copyright © 2022 Concordia CERC group +Project Coder Peter Yefi peteryefi@gmail.com +""" + +from persistence import BaseRepo +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import select +from persistence.models import User +from persistence.models import UserRoles +from helpers.auth import Auth +from typing import Union, Dict + + +class UserRepo(BaseRepo): + _instance = None + + def __init__(self, db_name: str, dotenv_path: str, app_env: str): + super().__init__(db_name, dotenv_path, app_env) + + def __new__(cls, db_name, dotenv_path, app_env): + """ + Implemented for a singleton pattern + """ + if cls._instance is None: + cls._instance = super(UserRepo, cls).__new__(cls) + return cls._instance + + def insert(self, name: str, email: str, password: str, role: UserRoles) -> Union[User, Dict]: + user = self.get_by_email(email) + if user is None: + try: + if Auth.validate_password(password): + user = User(name=name, email=email, password=Auth.hash_password(password), role=role) + self.session.add(user) + self.session.flush() + self.session.commit() + return user + except SQLAlchemyError as err: + print(f'An error occured while creating user: {err}') + else: + return {'message': f'user with {email} email already exists'} + + def update(self, user_id: int, name: str, email: str, password: str, role: UserRoles): + """ + Updates a user + :param user_id: the id of the user to be updated + :param name: the name of the user + :param email: the email of the user + :param password: the password of the user + :param role: the role of the user + :return: + """ + try: + if Auth.validate_password(password): + self.session.query(User).filter(User.id == user_id) \ + .update({'name': name, 'email': email, 'password': Auth.hash_password(password), 'role': role}) + self.session.commit() + except SQLAlchemyError as err: + print(f'Error while updating user: {err}') + + def get_by_email(self, email: str) -> [User]: + """ + Fetch user based on the email address + :param email: the email of the user + :return: [User] with the provided email + """ + try: + return self.session.execute(select(User).where(User.email == email)).first() + except SQLAlchemyError as err: + print(f'Error while fetching user by email: {err}') + + def delete_user(self, user_id: int): + """ + Deletes a user with the id + :param user_id: the user id + :return: None + """ + try: + self.session.query(User).filter(User.id == user_id).delete() + self.session.commit() + except SQLAlchemyError as err: + print(f'Error while fetching user: {err}') + + def get_user_by_email_and_password(self, email: str, password: str) -> [User]: + """ + Fetch user based on the email and password + :param email: the email of the user + :param password: the password of the user + :return: [User] with the provided email and password + """ + try: + user = self.session.execute(select(User).where(User.email == email)).first() + if user: + if Auth.check_password(password, user[0].password): + return user + return {'message': 'user not found'} + except SQLAlchemyError as err: + print(f'Error while fetching user by email: {err}') diff --git a/requirements.txt b/requirements.txt index 51a47411..1ef1383d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ PyYAML pyecore==0.12.2 python-dotenv SQLAlchemy +bcrypt==4.0.1