diff --git a/src/bot/translation/de.json b/src/bot/translation/de.json index 03b69e95c9..5ffe9dcf36 100644 --- a/src/bot/translation/de.json +++ b/src/bot/translation/de.json @@ -159,6 +159,16 @@ "subject": "Krümmelmonster Web Interface Test-Mail", "message": "Dies ist eine Test-Mail vom Krümmelmonster Web Interface\nGesendet von {}-{}" } - } + }, + "auth": { + "confirmation": { + "subject": "E-Mail für {} {} bestätigen", + "message": "Öffne den Link um die E-Mail zu bestätigen:\n{}auth/forgot-password/{}" + }, + "forgot_password": { + "subject": "Passwort für {} {} zurücksetzen", + "message": "Öffne den Link um das Passwort zu ändern:\n{}auth/forgot-password/{}" + } + } } } \ No newline at end of file diff --git a/src/bot_api/abc/auth_user_transformer_abc.py b/src/bot_api/abc/auth_user_transformer_abc.py new file mode 100644 index 0000000000..fd273847c5 --- /dev/null +++ b/src/bot_api/abc/auth_user_transformer_abc.py @@ -0,0 +1,16 @@ +from abc import abstractmethod + +from cpl_core.database import TableABC + +from bot_api.abc.dto_abc import DtoABC + + +class AuthUserTransformerABC: + + @staticmethod + @abstractmethod + def to_db(dto: DtoABC) -> TableABC: pass + + @staticmethod + @abstractmethod + def to_dto(db: TableABC) -> DtoABC: pass diff --git a/src/bot_api/bot-api.json b/src/bot_api/bot-api.json index 7b66f8efe5..a807eeb181 100644 --- a/src/bot_api/bot-api.json +++ b/src/bot_api/bot-api.json @@ -21,6 +21,7 @@ "Flask[async]==2.2.2", "Flask-Classful==0.14.2", "Flask-Cors==3.0.10", + "PyJWT[crypto]==2.5.0", "PyJWT==2.5.0" ], "DevDependencies": [ diff --git a/src/bot_api/model/email_string_dto.py b/src/bot_api/model/email_string_dto.py index bc70239dc4..1f5ef08738 100644 --- a/src/bot_api/model/email_string_dto.py +++ b/src/bot_api/model/email_string_dto.py @@ -7,12 +7,15 @@ from bot_api.abc.dto_abc import DtoABC class EMailStringDTO(DtoABC): - def __init__(self): + def __init__(self, email: str): DtoABC.__init__(self) + self._email = email + def from_dict(self, values: dict): - pass + self._email = values['EMail'] def to_dict(self) -> dict: return { + 'EMail': self._email } diff --git a/src/bot_api/model/reset_password_dto.py b/src/bot_api/model/reset_password_dto.py index 240437cdfa..0bacce596e 100644 --- a/src/bot_api/model/reset_password_dto.py +++ b/src/bot_api/model/reset_password_dto.py @@ -7,12 +7,26 @@ from bot_api.abc.dto_abc import DtoABC class ResetPasswordDTO(DtoABC): - def __init__(self): + def __init__(self, id: str, password: str): DtoABC.__init__(self) + self._id = id + self._password = password + + @property + def id(self) -> str: + return self._id + + @property + def password(self) -> str: + return self._password + def from_dict(self, values: dict): - pass + self._id = values['Id'] + self._password = values['Password'] def to_dict(self) -> dict: return { + 'Id': self._id, + 'Password': self._password } diff --git a/src/bot_api/model/update_auth_user_dto.py b/src/bot_api/model/update_auth_user_dto.py index b7d501f587..7c481330a4 100644 --- a/src/bot_api/model/update_auth_user_dto.py +++ b/src/bot_api/model/update_auth_user_dto.py @@ -3,16 +3,43 @@ import traceback from cpl_core.console import Console from bot_api.abc.dto_abc import DtoABC +from bot_api.model.auth_user_dto import AuthUserDTO class UpdateAuthUserDTO(DtoABC): - def __init__(self): + def __init__( + self, + auth_user: AuthUserDTO, + new_auth_user: AuthUserDTO, + change_password=False + ): DtoABC.__init__(self) + self._auth_user = auth_user + self._new_auth_user = new_auth_user + self._change_password = change_password + + @property + def auth_user(self) -> AuthUserDTO: + return self._auth_user + + @property + def new_auth_user(self) -> AuthUserDTO: + return self._new_auth_user + + @property + def change_password(self) -> bool: + return self._change_password + def from_dict(self, values: dict): - pass + self._auth_user = values['AuthUser'] + self._new_auth_user = values['NewAuthUser'] + self._change_password = False if 'ChangePassword' not in values else values['ChangePassword'] def to_dict(self) -> dict: return { + 'AuthUser': self._auth_user, + 'NewAuthUser': self._new_auth_user, + 'ChangePassword': self._change_password } diff --git a/src/bot_api/service/auth_service.py b/src/bot_api/service/auth_service.py index 7aa072dc04..05283d4e60 100644 --- a/src/bot_api/service/auth_service.py +++ b/src/bot_api/service/auth_service.py @@ -1,13 +1,25 @@ +import hashlib +import re +import uuid +from typing import Optional + +from cpl_core.database.context import DatabaseContextABC +from cpl_core.mailing import EMailClientABC, EMail from cpl_query.extension import List +from cpl_translation import TranslatePipe from bot_api.abc.auth_service_abc import AuthServiceABC +from bot_api.configuration.frontend_settings import FrontendSettings from bot_api.filter.auth_user_select_criteria import AuthUserSelectCriteria from bot_api.logging.api_logger import ApiLogger -from bot_api.model.auth_user_dto import AuthUserDTO +from bot_api.model.auth_user import AuthUserDTO from bot_api.model.auth_user_filtered_result_dto import AuthUserFilteredResultDTO +from bot_api.model.email_string_dto import EMailStringDTO from bot_api.model.reset_password_dto import ResetPasswordDTO from bot_api.model.token_dto import TokenDTO from bot_api.model.update_auth_user_dto import UpdateAuthUserDTO +from bot_api.transformer.auth_user_transformer import AuthUserTransformer as AUT +from bot_data.abc.auth_user_repository_abc import AuthUserRepositoryABC from bot_data.model.auth_user import AuthUser @@ -16,46 +28,235 @@ class AuthService(AuthServiceABC): def __init__( self, logger: ApiLogger, + auth_users: AuthUserRepositoryABC, + db: DatabaseContextABC, + mailer: EMailClientABC, + t: TranslatePipe, + frontend_settings: FrontendSettings, + ): AuthServiceABC.__init__(self) self._logger = logger + self._auth_users = auth_users + self._db = db + self._mailer = mailer + self._t = t + self._frontend_settings = frontend_settings + + @staticmethod + def _get_mail_to_send() -> EMail: + mail = EMail() + mail.add_header('Mime-Version: 1.0') + mail.add_header('Content-Type: text/plain charset=utf-8') + mail.add_header('Content-Transfer-Encoding: quoted-printable') + return mail + + @staticmethod + def _hash_sha256(password: str) -> str: + return hashlib.sha256(password.encode('utf-8')).hexdigest() + + @staticmethod + def _is_email_valid(email: str) -> bool: + regex = '^[a-z0-9]+[\\._]?[a-z0-9]+[@]\\w+[.]\\w{2,3}$' + return bool(re.search(regex, email)) + + def _send_confirmation_id_to_user(self, user: AuthUser): + url = self._frontend_settings.url + if not url.endswith('/'): + url = f'{url}/' + + mail = self._get_mail_to_send() + mail.add_receiver(user.email) + mail.subject = self._t.transform('api.auth.confirmation.subject').format(user.first_name, user.last_name) + mail.body = self._t.transform('api.auth.confirmation.message').format(url, user.confirmation_id) + self._mailer.send_mail(mail) + + def _send_forgot_password_id_to_user(self, user: AuthUser): + url = self._frontend_settings.url + if not url.endswith('/'): + url = f'{url}/' + + mail = self._get_mail_to_send() + mail.add_receiver(user.email) + mail.subject = self._t.transform('api.auth.forgot_password.subject').format(user.first_name, user.last_name) + mail.body = self._t.transform('api.auth.forgot_password.message').format(url, user.forgot_password_id) + self._mailer.send_mail(mail) async def get_all_auth_users_async(self) -> List[AuthUser]: - pass + result = self._auth_users.get_all_auth_users() \ + .select(lambda x: AUT.to_dto(x)) + return List(AuthUserDTO, result) async def get_filtered_auth_users_async(self, criteria: AuthUserSelectCriteria) -> AuthUserFilteredResultDTO: - pass + users = self._auth_users.get_filtered_auth_users(criteria) + result = users.result.select(lambda x: AUT.to_dto(x)) + + return AuthUserFilteredResultDTO( + List(AuthUserDTO, result), + users.total_count + ) async def get_auth_user_by_email_async(self, email: str) -> AuthUser: - pass + try: + user = self._auth_users.get_auth_user_by_email(email) + return user + except Exception as e: + self._logger.error(__name__, f'AuthUser not found', e) + raise Exception(f'User not found {email}') - async def find_auth_user_by_email_async(self, email: str) -> AuthUser: - pass + async def find_auth_user_by_email_async(self, email: str) -> Optional[AuthUser]: + user = self._auth_users.find_auth_user_by_email(email) + return AUT.to_dto(user) if user is not None else None async def add_auth_user_async(self, user_dto: AuthUserDTO) -> AuthUser: pass - async def confirm_email_async(self, id: str) -> AuthUser: - pass + async def confirm_email_async(self, id: str) -> bool: + user = self._auth_users.find_auth_user_by_confirmation_id(id) + if user is None: + return False + + user.confirmation_id = None + self._auth_users.update_auth_user(user) + self._db.save_changes() + return True async def login_async(self, user_dto: AuthUserDTO) -> TokenDTO: pass - async def forgot_password_async(self, email: str) -> AuthUser: - pass + async def forgot_password_async(self, email: str): + user = self._auth_users.find_auth_user_by_email(email) + if user is None: + return - async def confirm_forgot_password_async(self, id: str) -> AuthUser: - pass + user.forgot_password_id = uuid.uuid4() + self._auth_users.update_auth_user(user) + self._send_forgot_password_id_to_user(user) + self._db.save_changes() - async def reset_password_async(self, rp_dto: ResetPasswordDTO) -> AuthUser: - pass + async def confirm_forgot_password_async(self, id: str) -> EMailStringDTO: + user = self._auth_users.find_auth_user_by_forgot_password_id(id) + return EMailStringDTO(user.email) - async def update_user_async(self, update_user_dto: UpdateAuthUserDTO) -> AuthUser: - pass + async def reset_password_async(self, rp_dto: ResetPasswordDTO): + user = self._auth_users.find_auth_user_by_forgot_password_id(rp_dto.id) + if user is None: + pass - async def update_user_as_admin_async(self, update_user_dto: UpdateAuthUserDTO) -> AuthUser: - pass + if user.confirmation_id is not None: + pass + + if user.password is None or rp_dto.password == '': + pass + + user.password = self._hash_sha256(rp_dto.password) + self._db.save_changes() + + async def update_user_async(self, update_user_dto: UpdateAuthUserDTO): + if update_user_dto is None: + raise Exception(f'User is empty') + + if update_user_dto.auth_user is None: + raise Exception(f'Existing user is empty') + + if update_user_dto.new_auth_user is None: + raise Exception(f'New user is empty') + + if not self._is_email_valid(update_user_dto.auth_user.email) or not self._is_email_valid(update_user_dto.new_auth_user.email): + raise Exception(f'Invalid E-Mail') + + user = self._auth_users.find_auth_user_by_email(update_user_dto.auth_user.email) + if user is None: + raise Exception('User not found') + + if user.confirmation_id is not None: + raise Exception('E-Mail not confirmed') + + # update first name + if update_user_dto.new_auth_user.first_name is not None and update_user_dto.auth_user.first_name != update_user_dto.new_auth_user.first_name: + user.FirstName = update_user_dto.new_auth_user.first_name + + # update last name + if update_user_dto.new_auth_user.last_name is not None and update_user_dto.new_auth_user.last_name != '' and \ + update_user_dto.auth_user.last_name != update_user_dto.new_auth_user.last_name: + user.LastName = update_user_dto.new_auth_user.last_name + + # update E-Mail + if update_user_dto.new_auth_user.email is not None and update_user_dto.new_auth_user.email != '' and update_user_dto.auth_user.email != update_user_dto.new_auth_user.email: + user_by_new_e_mail = self._auth_users.find_auth_user_by_email(update_user_dto.new_auth_user.email) + if user_by_new_e_mail is not None: + raise Exception('User already exists') + user.email = update_user_dto.new_auth_user.email + + is_existing_password_set = False + is_new_password_set = False + # hash passwords in DTOs + if update_user_dto.auth_user.Password is not None and update_user_dto.auth_user.Password != '': + is_existing_password_set = True + update_user_dto.auth_user.Password = self._hash_sha256(update_user_dto.auth_user.Password) + + if update_user_dto.auth_user.Password != user.Password: + raise Exception('Wrong password') + + if update_user_dto.new_auth_user.Password is not None and update_user_dto.new_auth_user.Password != '': + is_new_password_set = True + update_user_dto.new_auth_user.Password = self._hash_sha256(update_user_dto.new_auth_user.Password) + + # update password + if is_existing_password_set and is_new_password_set and update_user_dto.auth_user.Password != update_user_dto.new_auth_user.Password: + user.Password = update_user_dto.new_auth_user.Password + + self._db.save_changes() + + async def update_user_as_admin_async(self, update_user_dto: UpdateAuthUserDTO): + if update_user_dto is None: + raise Exception(f'User is empty') + + if update_user_dto.auth_user is None: + raise Exception(f'Existing user is empty') + + if update_user_dto.new_auth_user is None: + raise Exception(f'New user is empty') + + if not self._is_email_valid(update_user_dto.auth_user.email) or not self._is_email_valid(update_user_dto.new_auth_user.email): + raise Exception(f'Invalid E-Mail') + + user = self._auth_users.find_auth_user_by_email(update_user_dto.auth_user.email) + if user is None: + raise Exception('User not found') + + if user.ConfirmationId is not None and update_user_dto.new_auth_user.is_confirmed: + user.ConfirmationId = None + elif user.ConfirmationId is None and not update_user_dto.new_auth_user.is_confirmed: + user.confirmation_id = uuid.uuid4() + # else + # raise Exception(ServiceErrorCode.InvalidUser, 'E-Mail not confirmed') + + # update first name + if update_user_dto.new_auth_user.first_name is not None and update_user_dto.auth_user.first_name != update_user_dto.new_auth_user.first_name: + user.FirstName = update_user_dto.new_auth_user.first_name + + # update last name + if update_user_dto.new_auth_user.last_name is not None and update_user_dto.new_auth_user.last_name != '' and update_user_dto.auth_user.last_name != update_user_dto.new_auth_user.last_name: + user.LastName = update_user_dto.new_auth_user.last_name + + # update E-Mail + if update_user_dto.new_auth_user.email is not None and update_user_dto.new_auth_user.email != '' and update_user_dto.auth_user.email != update_user_dto.new_auth_user.email: + user_by_new_e_mail = self._auth_users.find_auth_user_by_email(update_user_dto.new_auth_user.email) + if user_by_new_e_mail is not None: + raise Exception('User already exists') + user.EMail = update_user_dto.new_auth_user.email + + # update password + if update_user_dto.change_password and update_user_dto.auth_user.password != update_user_dto.new_auth_user.password: + user.Password = self._hash_sha256(update_user_dto.new_auth_user.password) + + # update role + if user.auth_role == update_user_dto.auth_user.auth_role and user.auth_role != update_user_dto.new_auth_user.auth_role: + user.auth_role = update_user_dto.new_auth_user.auth_role + + self._db.save_changes() async def refresh_async(self, token_dto: TokenDTO) -> AuthUser: pass @@ -63,8 +264,19 @@ class AuthService(AuthServiceABC): async def revoke_async(self, token_dto: TokenDTO) -> AuthUser: pass - async def delete_auth_user_by_email_async(self, email: str) -> AuthUser: - pass + async def delete_auth_user_by_email_async(self, email: str): + try: + user = self._auth_users.get_auth_user_by_email(email) + self._auth_users.delete_auth_user(user) + self._db.save_changes() + except Exception as e: + self._logger.error(__name__, f'Cannot delete user', e) + raise Exception(f'Cannot delete user by mail {email}') - async def delete_auth_user_async(self, user_dto: AuthUserDTO) -> AuthUser: - pass + async def delete_auth_user_async(self, user_dto: AuthUserDTO): + try: + self._auth_users.delete_auth_user(AUT.to_db(user_dto)) + self._db.save_changes() + except Exception as e: + self._logger.error(__name__, f'Cannot delete user', e) + raise Exception(f'Cannot delete user by mail {user_dto.email}') diff --git a/src/bot_api/transformer/__init__.py b/src/bot_api/transformer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bot_api/transformer/auth_user_transformer.py b/src/bot_api/transformer/auth_user_transformer.py new file mode 100644 index 0000000000..50258b016e --- /dev/null +++ b/src/bot_api/transformer/auth_user_transformer.py @@ -0,0 +1,33 @@ +from bot_api.abc.auth_user_transformer_abc import AuthUserTransformerABC +from bot_api.model.auth_user_dto import AuthUserDTO +from bot_data.model.auth_user import AuthUser + + +class AuthUserTransformer(AuthUserTransformerABC): + + @staticmethod + def to_db(dto: AuthUserDTO) -> AuthUser: + return AuthUser( + dto.first_name, + dto.last_name, + dto.email, + dto.password, + None, + None, + None, + None, + dto.auth_role, + id=dto.id + ) + + @staticmethod + def to_dto(db: AuthUser) -> AuthUserDTO: + return AuthUserDTO( + db.id, + db.first_name, + db.last_name, + db.email, + db.password, + db.confirmation_id is None, + db.auth_role + ) diff --git a/src/bot_data/abc/auth_user_repository_abc.py b/src/bot_data/abc/auth_user_repository_abc.py index 161187461e..a376a273ac 100644 --- a/src/bot_data/abc/auth_user_repository_abc.py +++ b/src/bot_data/abc/auth_user_repository_abc.py @@ -17,19 +17,19 @@ class AuthUserRepositoryABC(ABC): def get_all_auth_users(self) -> List[AuthUser]: pass @abstractmethod - def get_filtered_auth_users_async(self, criteria: AuthUserSelectCriteria) -> FilteredResult: pass + def get_filtered_auth_users(self, criteria: AuthUserSelectCriteria) -> FilteredResult: pass @abstractmethod - def get_auth_user_by_email_async(self, email: str) -> AuthUser: pass + def get_auth_user_by_email(self, email: str) -> AuthUser: pass @abstractmethod - def find_auth_user_by_email_async(self, email: str) -> Optional[AuthUser]: pass + def find_auth_user_by_email(self, email: str) -> Optional[AuthUser]: pass @abstractmethod - def find_auth_user_by_confirmation_id_async(self, id: str) -> Optional[AuthUser]: pass + def find_auth_user_by_confirmation_id(self, id: str) -> Optional[AuthUser]: pass @abstractmethod - def find_auth_user_by_forgot_password_id_async(self, id: str) -> Optional[AuthUser]: pass + def find_auth_user_by_forgot_password_id(self, id: str) -> Optional[AuthUser]: pass @abstractmethod def add_auth_user(self, user: AuthUser): pass diff --git a/src/bot_data/model/auth_user.py b/src/bot_data/model/auth_user.py index 6078f2099b..eeb90d76e0 100644 --- a/src/bot_data/model/auth_user.py +++ b/src/bot_data/model/auth_user.py @@ -14,10 +14,10 @@ class AuthUser(TableABC): last_name: str, email: str, password: str, - refresh_token: str, - confirmation_id: str, - forgot_password_id: str, - refresh_token_expire_time: datetime, + refresh_token: Optional[str], + confirmation_id: Optional[str], + forgot_password_id: Optional[str], + refresh_token_expire_time: Optional[datetime], auth_role: AuthRoleEnum, created_at: datetime = None, modified_at: datetime = None, @@ -47,30 +47,74 @@ class AuthUser(TableABC): def first_name(self) -> str: return self._first_name + @first_name.setter + def first_name(self, value: str): + self._first_name = value + @property def last_name(self) -> str: return self._last_name + @last_name.setter + def last_name(self, value: str): + self._last_name = value + @property def email(self) -> str: return self._email + @email.setter + def email(self, value: str): + self._email = value + @property def password(self) -> str: return self._password - @property - def refresh_token(self) -> str: - return self._refresh_token + @password.setter + def password(self, value: str): + self._password = value @property - def refresh_token_expire_time(self) -> datetime: + def refresh_token(self) -> Optional[str]: + return self._refresh_token + + @refresh_token.setter + def refresh_token(self, value: Optional[str]): + self._refresh_token = value + + @property + def confirmation_id(self) -> Optional[str]: + return self._confirmation_id + + @confirmation_id.setter + def confirmation_id(self, value: Optional[str]): + self._confirmation_id = value + + @property + def forgot_password_id(self) -> Optional[str]: + return self._forgot_password_id + + @forgot_password_id.setter + def forgot_password_id(self, value: Optional[str]): + self._forgot_password_id = value + + @property + def refresh_token_expire_time(self) -> Optional[datetime]: return self._refresh_token_expire_time + @refresh_token_expire_time.setter + def refresh_token_expire_time(self, value: Optional[datetime]): + self._refresh_token_expire_time = value + @property def auth_role(self) -> AuthRoleEnum: return self._auth_role_id + @auth_role.setter + def auth_role(self, value: AuthRoleEnum): + self._auth_role_id = value + @staticmethod def get_select_all_string() -> str: return str(f""" diff --git a/src/bot_data/service/auth_user_repository_service.py b/src/bot_data/service/auth_user_repository_service.py index aca3e94af2..2096113e34 100644 --- a/src/bot_data/service/auth_user_repository_service.py +++ b/src/bot_data/service/auth_user_repository_service.py @@ -44,7 +44,7 @@ class AuthUserRepositoryService(AuthUserRepositoryABC): return users - def get_filtered_auth_users_async(self, criteria: AuthUserSelectCriteria) -> FilteredResult: + def get_filtered_auth_users(self, criteria: AuthUserSelectCriteria) -> FilteredResult: users = self.get_all_auth_users() self._logger.trace(__name__, f'Send SQL command: {AuthUser.get_select_all_string()}') @@ -77,12 +77,12 @@ class AuthUserRepositoryService(AuthUserRepositoryABC): return result - def get_auth_user_by_email_async(self, email: str) -> AuthUser: + def get_auth_user_by_email(self, email: str) -> AuthUser: self._logger.trace(__name__, f'Send SQL command: {AuthUser.get_select_by_email_string(email)}') result = self._context.select(AuthUser.get_select_by_email_string(email))[0] return self._user_from_result(result) - def find_auth_user_by_email_async(self, email: str) -> Optional[AuthUser]: + def find_auth_user_by_email(self, email: str) -> Optional[AuthUser]: self._logger.trace(__name__, f'Send SQL command: {AuthUser.get_select_by_email_string(email)}') result = self._context.select(AuthUser.get_select_by_email_string(email)) if result is None or len(result) == 0: @@ -92,7 +92,7 @@ class AuthUserRepositoryService(AuthUserRepositoryABC): return self._user_from_result(result) - def find_auth_user_by_confirmation_id_async(self, id: str) -> Optional[AuthUser]: + def find_auth_user_by_confirmation_id(self, id: str) -> Optional[AuthUser]: self._logger.trace(__name__, f'Send SQL command: {AuthUser.get_select_by_email_string(id)}') result = self._context.select(AuthUser.get_select_by_email_string(id)) if result is None or len(result) == 0: @@ -102,7 +102,7 @@ class AuthUserRepositoryService(AuthUserRepositoryABC): return self._user_from_result(result) - def find_auth_user_by_forgot_password_id_async(self, id: str) -> Optional[AuthUser]: + def find_auth_user_by_forgot_password_id(self, id: str) -> Optional[AuthUser]: self._logger.trace(__name__, f'Send SQL command: {AuthUser.get_select_by_email_string(id)}') result = self._context.select(AuthUser.get_select_by_email_string(id)) if result is None or len(result) == 0: