Added user authentication capabilities to hub

This commit is contained in:
Peter Yefi 2022-12-08 20:54:47 -05:00
parent 23ce8665a2
commit 4693edd5e8
14 changed files with 299 additions and 252 deletions

View File

@ -46,12 +46,12 @@ from exports.db_factory import DBFactory
from pathlib import Path from pathlib import Path
dotenv_path = (Path(__file__).parent / '.env').resolve() 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 ## ## 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 ```python
from persistence import DBSetup from persistence import DBSetup
from pathlib import Path from pathlib import Path
@ -59,3 +59,8 @@ from pathlib import Path
dotenv_path = (Path(__file__).parent / '.env').resolve() dotenv_path = (Path(__file__).parent / '.env').resolve()
DBSetup(db_name='hub_db', app_env='PROD', dotenv_path=dotenv_path) DBSetup(db_name='hub_db', app_env='PROD', dotenv_path=dotenv_path)
``` ```
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**

31
exports/user_factory.py Normal file
View File

@ -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)

43
helpers/auth.py Normal file
View File

@ -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'))

View File

@ -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

45
imports/user_factory.py Normal file
View File

@ -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)

View File

@ -1,5 +1,6 @@
from .base_repo import BaseRepo from .base_repo import BaseRepo
from .repositories.city_repo import CityRepo from .repositories.city_repo import CityRepo
from .repositories.building_repo import BuildingRepo
from .repositories.heat_pump_simulation_repo import HeatPumpSimulationRepo from .repositories.heat_pump_simulation_repo import HeatPumpSimulationRepo
from .db_setup import DBSetup from .db_setup import DBSetup
from .repositories.user_repo import UserRepo
from .models.user import UserRoles

View File

@ -1,11 +1,31 @@
from persistence.models import City from persistence.models import City
from persistence import BaseRepo from persistence import BaseRepo
from persistence.models import HeatPumpSimulation from persistence.models import HeatPumpSimulation
from persistence.models import User
from persistence.repositories import UserRepo
from persistence.models import UserRoles
class DBSetup: class DBSetup:
def __init__(self, db_name, app_env, dotenv_path): 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) repo = BaseRepo(db_name=db_name, app_env=app_env, dotenv_path=dotenv_path)
City.__table__.create(bind=repo.engine, checkfirst=True) City.__table__.create(bind=repo.engine, checkfirst=True)
HeatPumpSimulation.__table__.create(bind=repo.engine, checkfirst=True) 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')

View File

@ -1,5 +1,5 @@
from .building import Building
from .city import City from .city import City
from .heat_pump_simulation import HeatPumpSimulation from .heat_pump_simulation import HeatPumpSimulation
from .heat_pump_simulation import SimulationTypes from .heat_pump_simulation import SimulationTypes
from .heat_pump_simulation import HeatPumpTypes from .heat_pump_simulation import HeatPumpTypes
from .user import User, UserRoles

View File

@ -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)

View File

@ -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

View File

@ -1 +1 @@
from .user_repo import UserRepo

View File

@ -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}')

View File

@ -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}')

View File

@ -18,3 +18,4 @@ PyYAML
pyecore==0.12.2 pyecore==0.12.2
python-dotenv python-dotenv
SQLAlchemy SQLAlchemy
bcrypt==4.0.1