From b3bb6b8f8a473007339364cfa35c781b066f13cc Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 16 Jul 2024 10:42:56 -0300 Subject: [PATCH 01/29] Add forgot_password/reset_password api's to User service --- .../src/syft/service/user/user_service.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index fd468703190..a4e42805aa6 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -1,4 +1,6 @@ # stdlib +import random +import string # relative from ...abstract_server import ServerType @@ -84,6 +86,100 @@ def create( user = result.ok() return user.to(UserView) + @service_method( + path="user.forgot_password", name="forgot_password", roles=GUEST_ROLE_LEVEL + ) + def forgot_password( + self, context: AuthedServiceContext, email: str + ) -> SyftSuccess | SyftError: + result = self.stash.get_by_email(credentials=context.credentials, email=email) + + if result.is_err(): + return SyftSuccess( + message="If the email is valid, we sent a password request to the admin." + ) + + user = result.ok() + root_key = self.admin_verify_key() + root_context = AuthedServiceContext(server=context.server, credentials=root_key) + + link = LinkedObject.with_context(user, context=root_context) + + message = CreateNotification( + subject="You requested password reset.", + from_user_verify_key=root_key, + to_user_verify_key=user.verify_key, + linked_obj=link, + ) + + method = context.server.get_service_method(NotificationService.send) + result = method(context=root_context, notification=message) + + message = CreateNotification( + subject=" User requested password reset.", + from_user_verify_key=user.verify_key, + to_user_verify_key=root_key, + linked_obj=link, + ) + + result = method(context=root_context, notification=message) + return SyftSuccess( + message="If the email is valid, we sent a password request to the admin." + ) + + @service_method( + path="user.reset_password", name="reset_password", roles=ADMIN_ROLE_LEVEL + ) + def reset(self, context: AuthedServiceContext, uid: UID) -> SyftSuccess | SyftError: + """Get user for given uid""" + result = self.stash.get_by_uid(credentials=context.credentials, uid=uid) + if result.is_ok(): + user = result.ok() + if user is None: + return SyftError(message=f"No user exists for given: {uid}") + + password_length = 12 + valid_characters = string.ascii_letters + string.digits + new_password = "".join( + random.choice(valid_characters) for i in range(password_length) + ) + salt, hashed = salt_and_hash_password(new_password, password_length) + user.hashed_password = hashed + user.salt = salt + result = self.stash.update( + credentials=context.credentials, user=user, has_permission=True + ) + if result.is_err(): + return SyftError( + message=( + f"Failed to update user with UID: {uid}. Error: {str(result.err())}" + ) + ) + + # # Notification Setup + # root_key = self.admin_verify_key() + # root_context = AuthedServiceContext(server=context.server, credentials=root_key) + # link = None + # if new_user.created_by: + # link = LinkedObject.with_context(user, context=root_context) + # + # message = CreateNotification( + # subject=success_message, + # from_user_verify_key=root_key, + # to_user_verify_key=user.verify_key, + # linked_obj=link, + # notifier_types=[NOTIFIERS.EMAIL], + # email_template=OnBoardEmailTemplate, + # ) + + # method = context.server.get_service_method(NotificationService.send) + # result = method(context=root_context, notification=message) + + return SyftSuccess( + message=f"User password has been reset successfully!\n New User Password: {new_password}" + ) + return SyftError(message=str(result.err())) + @service_method(path="user.view", name="view", roles=DATA_SCIENTIST_ROLE_LEVEL) def view( self, context: AuthedServiceContext, uid: UID From 95341523d8b9a9dba0141391bde0b59119c31208 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 16 Jul 2024 15:55:50 -0300 Subject: [PATCH 02/29] Fix Reset Password Email Template --- .../service/notification/email_templates.py | 88 +++++++++++++++++++ .../src/syft/service/user/user_service.py | 64 +++++++++++--- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/syft/src/syft/service/notification/email_templates.py b/packages/syft/src/syft/service/notification/email_templates.py index 1a6965365dc..1a2edadc4f0 100644 --- a/packages/syft/src/syft/service/notification/email_templates.py +++ b/packages/syft/src/syft/service/notification/email_templates.py @@ -1,10 +1,13 @@ # stdlib +import random +import string from typing import TYPE_CHECKING from typing import cast # relative from ...store.linked_obj import LinkedObject from ..context import AuthedServiceContext +from ..user.user import salt_and_hash_password if TYPE_CHECKING: # relative @@ -21,6 +24,91 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s return "" +class PasswordResetTemplate(EmailTemplate): + @staticmethod + def email_title(notification: "Notification", context: AuthedServiceContext) -> str: + return "Password Reset Requested" + + @staticmethod + def email_body(notification: "Notification", context: AuthedServiceContext) -> str: + user_service = context.server.get_service("userservice") + + user = user_service.get_by_verify_key(notification.to_user_verify_key) + if not user: + raise Exception("User not found!") + + password_length = 12 + valid_characters = string.ascii_letters + string.digits + new_password = "".join( + random.choice(valid_characters) for i in range(password_length) + ) + salt, hashed = salt_and_hash_password(new_password, password_length) + user.hashed_password = hashed + user.salt = salt + + result = user_service.stash.update( + credentials=context.credentials, user=user, has_permission=True + ) + if result.is_err(): + raise Exception("Couldn't update the user password") + + head = """ + + """ + body = f""" +
+

Password Reset

+

Hello,

+

We received a request to reset your password. Your new temporary password is:

+

{new_password}

+

If you didn't request a password reset, please ignore this email.

+
+ """ + return f"""{head} {body}""" + + class OnBoardEmailTemplate(EmailTemplate): @staticmethod def email_title(notification: "Notification", context: AuthedServiceContext) -> str: diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index a4e42805aa6..674022062b3 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -19,6 +19,7 @@ from ..context import ServerServiceContext from ..context import UnauthedServiceContext from ..notification.email_templates import OnBoardEmailTemplate +from ..notification.email_templates import PasswordResetTemplate from ..notification.notification_service import CreateNotification from ..notification.notification_service import NotificationService from ..notifier.notifier_enums import NOTIFIERS @@ -93,35 +94,72 @@ def forgot_password( self, context: AuthedServiceContext, email: str ) -> SyftSuccess | SyftError: result = self.stash.get_by_email(credentials=context.credentials, email=email) - + # Isn't a valid email if result.is_err(): return SyftSuccess( message="If the email is valid, we sent a password request to the admin." ) - user = result.ok() + # Email is valid + # Notifications disabled + # We should just sent a notification to the admin/user about password reset + # Notifications Enabled + # Instead of changing the password here, we would change it in email template generation. root_key = self.admin_verify_key() root_context = AuthedServiceContext(server=context.server, credentials=root_key) - + user = result.ok() link = LinkedObject.with_context(user, context=root_context) + notifier_service = context.server.get_service("notifierservice") + # Notifier is active + notification_is_enabled = notifier_service.settings(context=root_context).active + # Email is enabled + email_is_enabled = notifier_service.settings(context=root_context).email_enabled + # User Preferences allow email notification + user_allow_email_notifications = user.notifications_enabled[NOTIFIERS.EMAIL] + + # This checks if the user will safely receive the email reset. + not_receive_emails = ( + not notification_is_enabled + or not email_is_enabled + or not user_allow_email_notifications + ) + # If notifier service is not enabled. + if not_receive_emails: + message = CreateNotification( + subject="You requested password reset.", + from_user_verify_key=root_key, + to_user_verify_key=user.verify_key, + linked_obj=link, + ) + + method = context.server.get_service_method(NotificationService.send) + result = method(context=root_context, notification=message) + + message = CreateNotification( + subject="User requested password reset.", + from_user_verify_key=user.verify_key, + to_user_verify_key=root_key, + linked_obj=link, + ) + + result = method(context=root_context, notification=message) + return SyftSuccess( + message="If the email is valid, we sent a password request to the admin." + ) + + # Email notification is Enabled + # Therefore, we can directly send a message to the user with its new password. message = CreateNotification( - subject="You requested password reset.", + subject="You requested a password reset.", from_user_verify_key=root_key, to_user_verify_key=user.verify_key, linked_obj=link, + notifier_types=[NOTIFIERS.EMAIL], + email_template=PasswordResetTemplate, ) method = context.server.get_service_method(NotificationService.send) - result = method(context=root_context, notification=message) - - message = CreateNotification( - subject=" User requested password reset.", - from_user_verify_key=user.verify_key, - to_user_verify_key=root_key, - linked_obj=link, - ) - result = method(context=root_context, notification=message) return SyftSuccess( message="If the email is valid, we sent a password request to the admin." From 654930b06451d3aa043c7287596ff32e513e05bd Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 16 Jul 2024 16:40:54 -0300 Subject: [PATCH 03/29] Replace random by secrets --- .../syft/src/syft/service/notification/email_templates.py | 4 ++-- packages/syft/src/syft/service/user/user_service.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/syft/src/syft/service/notification/email_templates.py b/packages/syft/src/syft/service/notification/email_templates.py index 1a2edadc4f0..9e32decf44a 100644 --- a/packages/syft/src/syft/service/notification/email_templates.py +++ b/packages/syft/src/syft/service/notification/email_templates.py @@ -1,5 +1,5 @@ # stdlib -import random +import secrets import string from typing import TYPE_CHECKING from typing import cast @@ -40,7 +40,7 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s password_length = 12 valid_characters = string.ascii_letters + string.digits new_password = "".join( - random.choice(valid_characters) for i in range(password_length) + secrets.choice(valid_characters) for i in range(password_length) ) salt, hashed = salt_and_hash_password(new_password, password_length) user.hashed_password = hashed diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 674022062b3..fb93b74bee8 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -1,6 +1,6 @@ # stdlib -import random import string +import secrets # relative from ...abstract_server import ServerType @@ -179,7 +179,7 @@ def reset(self, context: AuthedServiceContext, uid: UID) -> SyftSuccess | SyftEr password_length = 12 valid_characters = string.ascii_letters + string.digits new_password = "".join( - random.choice(valid_characters) for i in range(password_length) + secrets.choice(valid_characters) for i in range(password_length) ) salt, hashed = salt_and_hash_password(new_password, password_length) user.hashed_password = hashed From b003dcd6b05e5354c9a0618c0cc3085efaf89a46 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 16 Jul 2024 16:51:22 -0300 Subject: [PATCH 04/29] Fix lint --- packages/syft/src/syft/service/user/user_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index fb93b74bee8..51822c92770 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -1,6 +1,6 @@ # stdlib -import string import secrets +import string # relative from ...abstract_server import ServerType From 1e134363d80bec8096a5a6f90e6399be00a8cfa3 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Sun, 21 Jul 2024 15:46:22 -0300 Subject: [PATCH 05/29] Change password reset workflow to use temporary tokens --- .../src/syft/protocol/protocol_version.json | 16 ++ .../service/notification/email_templates.py | 19 +-- packages/syft/src/syft/service/user/user.py | 17 +- .../src/syft/service/user/user_service.py | 147 ++++++++++++------ .../syft/src/syft/service/user/user_stash.py | 7 + 5 files changed, 144 insertions(+), 62 deletions(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index a7bb8d85399..b375a4847ae 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -1,5 +1,21 @@ { "1": { "release_name": "0.8.7.json" + }, + "dev": { + "object_versions": { + "User": { + "1": { + "version": 1, + "hash": "2df4b68182c558dba5485a8a6867acf2a5c341b249ad67373a504098aa8c4343", + "action": "remove" + }, + "2": { + "version": 2, + "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", + "action": "add" + } + } + } } } diff --git a/packages/syft/src/syft/service/notification/email_templates.py b/packages/syft/src/syft/service/notification/email_templates.py index a4636d19476..bd65592d498 100644 --- a/packages/syft/src/syft/service/notification/email_templates.py +++ b/packages/syft/src/syft/service/notification/email_templates.py @@ -1,6 +1,5 @@ # stdlib -import secrets -import string +from datetime import datetime from typing import TYPE_CHECKING from typing import cast @@ -8,7 +7,6 @@ from ...serde.serializable import serializable from ...store.linked_obj import LinkedObject from ..context import AuthedServiceContext -from ..user.user import salt_and_hash_password if TYPE_CHECKING: # relative @@ -24,6 +22,7 @@ def email_title(notification: "Notification", context: AuthedServiceContext) -> def email_body(notification: "Notification", context: AuthedServiceContext) -> str: return "" + @serializable(canonical_name="PasswordResetTemplate", version=1) class PasswordResetTemplate(EmailTemplate): @staticmethod @@ -38,14 +37,8 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s if not user: raise Exception("User not found!") - password_length = 12 - valid_characters = string.ascii_letters + string.digits - new_password = "".join( - secrets.choice(valid_characters) for i in range(password_length) - ) - salt, hashed = salt_and_hash_password(new_password, password_length) - user.hashed_password = hashed - user.salt = salt + user.reset_token = user_service.generate_new_password_reset_token() + user.reset_token_date = datetime.now() result = user_service.stash.update( credentials=context.credentials, user=user, has_permission=True @@ -102,8 +95,8 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s

Password Reset

Hello,

-

We received a request to reset your password. Your new temporary password is:

-

{new_password}

+

We received a request to reset your password. Your new temporary token is:

+

{user.reset_token}

If you didn't request a password reset, please ignore this email.

""" diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index 383ed3c5f6a..231b4a8f00a 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -1,6 +1,8 @@ # stdlib from collections.abc import Callable +from datetime import datetime from getpass import getpass +import re from typing import Any # third party @@ -19,6 +21,7 @@ from ...types.syft_metaclass import Empty from ...types.syft_object import PartialSyftObject from ...types.syft_object import SYFT_OBJECT_VERSION_1 +from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject from ...types.transforms import TransformContext from ...types.transforms import drop @@ -38,7 +41,7 @@ class User(SyftObject): # version __canonical_name__ = "User" - __version__ = SYFT_OBJECT_VERSION_1 + __version__ = SYFT_OBJECT_VERSION_2 id: UID | None = None # type: ignore[assignment] @@ -61,9 +64,10 @@ class User(SyftObject): created_at: str | None = None # TODO where do we put this flag? mock_execution_permission: bool = False - + reset_token: str | None = None + reset_token_date: datetime | None = None # serde / storage rules - __attr_searchable__ = ["name", "email", "verify_key", "role"] + __attr_searchable__ = ["name", "email", "verify_key", "role", "reset_token"] __attr_unique__ = ["email", "signing_key", "verify_key"] __repr_attrs__ = ["name", "email"] @@ -72,6 +76,13 @@ def default_role(role: ServiceRole) -> Callable: return make_set_default(key="role", value=role) +def validate_password(password: str) -> bool: + # Define the regex pattern for the password + pattern = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$") + + return bool(pattern.match(password)) + + def hash_password(context: TransformContext) -> TransformContext: if context.output is None: return context diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 9a0b283f18b..b4395497222 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -1,4 +1,6 @@ # stdlib +from datetime import datetime +from datetime import timedelta import secrets import string @@ -39,6 +41,7 @@ from .user import UserViewPage from .user import check_pwd from .user import salt_and_hash_password +from .user import validate_password from .user_roles import ADMIN_ROLE_LEVEL from .user_roles import DATA_OWNER_ROLE_LEVEL from .user_roles import DATA_SCIENTIST_ROLE_LEVEL @@ -97,7 +100,15 @@ def forgot_password( # Isn't a valid email if result.is_err(): return SyftSuccess( - message="If the email is valid, we sent a password request to the admin." + message="If the email is valid, we sent a password \ + reset token to your email or a password request to the admin." + ) + user = result.ok() + + user_role = self.get_role_for_credentials(user.verify_key) + if user_role == ServiceRole.ADMIN: + return SyftError( + message="You can't request password reset for an Admin user." ) # Email is valid @@ -107,7 +118,6 @@ def forgot_password( # Instead of changing the password here, we would change it in email template generation. root_key = self.admin_verify_key() root_context = AuthedServiceContext(server=context.server, credentials=root_key) - user = result.ok() link = LinkedObject.with_context(user, context=root_context) notifier_service = context.server.get_service("notifierservice") # Notifier is active @@ -145,7 +155,8 @@ def forgot_password( result = method(context=root_context, notification=message) return SyftSuccess( - message="If the email is valid, we sent a password request to the admin." + message="If the email is valid, we sent a password reset \ + token to your email or a password request to the admin." ) # Email notification is Enabled @@ -166,57 +177,101 @@ def forgot_password( ) @service_method( - path="user.reset_password", name="reset_password", roles=ADMIN_ROLE_LEVEL + path="user.request_password_reset", + name="request_password_reset", + roles=ADMIN_ROLE_LEVEL, ) - def reset(self, context: AuthedServiceContext, uid: UID) -> SyftSuccess | SyftError: - """Get user for given uid""" + def request_password_reset( + self, context: AuthedServiceContext, uid: UID + ) -> str | SyftError: result = self.stash.get_by_uid(credentials=context.credentials, uid=uid) - if result.is_ok(): - user = result.ok() - if user is None: - return SyftError(message=f"No user exists for given: {uid}") + if result.is_err(): + return SyftError( + message=( + f"Failed to retrieve user with UID: {uid}. Error: {str(result.err())}" + ) + ) + user = result.ok() + if user is None: + return SyftError(message=f"No user exists for given: {uid}") + + user.reset_token = self.generate_new_password_reset_token() + user.reset_token_date = datetime.now() - password_length = 12 - valid_characters = string.ascii_letters + string.digits - new_password = "".join( - secrets.choice(valid_characters) for i in range(password_length) + result = self.stash.update( + credentials=context.credentials, user=user, has_permission=True + ) + if result.is_err(): + return SyftError( + message=( + f"Failed to update user with UID: {uid}. Error: {str(result.err())}" + ) ) - salt, hashed = salt_and_hash_password(new_password, password_length) - user.hashed_password = hashed - user.salt = salt - result = self.stash.update( - credentials=context.credentials, user=user, has_permission=True + + return user.reset_token + + @service_method( + path="user.reset_password", name="reset_password", roles=GUEST_ROLE_LEVEL + ) + def reset_password( + self, context: AuthedServiceContext, token: str, new_password: str + ) -> SyftSuccess | SyftError: + """Resets a certain user password using a temporary token.""" + result = self.stash.get_by_reset_token( + credentials=context.credentials, token=token + ) + invalid_token_error = SyftError( + message=("Failed to reset user password. Token is invalid or expired!") + ) + + if result.is_err(): + return SyftError(message="Failed to reset user password.") + + user = result.ok() + + # If token isn't found + if user is None: + return invalid_token_error + + now = datetime.now() + time_difference = now - user.reset_token_date + + # If token expired + if time_difference > timedelta(minutes=30): + return invalid_token_error + + if not validate_password(new_password): + return SyftError( + message="Your new password must have at least 8 \ + characters, Upper case and lower case characters\ + and at least one number." ) - if result.is_err(): - return SyftError( - message=( - f"Failed to update user with UID: {uid}. Error: {str(result.err())}" - ) - ) - # # Notification Setup - # root_key = self.admin_verify_key() - # root_context = AuthedServiceContext(server=context.server, credentials=root_key) - # link = None - # if new_user.created_by: - # link = LinkedObject.with_context(user, context=root_context) - # - # message = CreateNotification( - # subject=success_message, - # from_user_verify_key=root_key, - # to_user_verify_key=user.verify_key, - # linked_obj=link, - # notifier_types=[NOTIFIERS.EMAIL], - # email_template=OnBoardEmailTemplate, - # ) - - # method = context.server.get_service_method(NotificationService.send) - # result = method(context=root_context, notification=message) + salt, hashed = salt_and_hash_password(new_password, len(new_password)) + user.hashed_password = hashed + user.salt = salt - return SyftSuccess( - message=f"User password has been reset successfully!\n New User Password: {new_password}" + user.reset_token = None + user.reset_token_date = None + + result = self.stash.update( + credentials=context.credentials, user=user, has_permission=True + ) + if result.is_err(): + return SyftError( + message=(f"Failed to update user password. Error: {str(result.err())}") ) - return SyftError(message=str(result.err())) + return SyftSuccess(message="User Password updated successfully!") + + def generate_new_password_reset_token(self) -> str: + token_len = 12 + valid_characters = string.ascii_letters + string.digits + + generated_token = "".join( + secrets.choice(valid_characters) for _ in range(token_len) + ) + + return generated_token @service_method(path="user.view", name="view", roles=DATA_SCIENTIST_ROLE_LEVEL) def view( diff --git a/packages/syft/src/syft/service/user/user_stash.py b/packages/syft/src/syft/service/user/user_stash.py index 2b2b42db9e8..894d9a65115 100644 --- a/packages/syft/src/syft/service/user/user_stash.py +++ b/packages/syft/src/syft/service/user/user_stash.py @@ -23,6 +23,7 @@ # 🟡 TODO 27: it would be nice if these could be defined closer to the User EmailPartitionKey = PartitionKey(key="email", type_=str) +PasswordResetTokenPartitionKey = PartitionKey(key="reset_token", type_=str) RolePartitionKey = PartitionKey(key="role", type_=ServiceRole) SigningKeyPartitionKey = PartitionKey(key="signing_key", type_=SyftSigningKey) VerifyKeyPartitionKey = PartitionKey(key="verify_key", type_=SyftVerifyKey) @@ -74,6 +75,12 @@ def get_by_uid( qks = QueryKeys(qks=[UIDPartitionKey.with_obj(uid)]) return self.query_one(credentials=credentials, qks=qks) + def get_by_reset_token( + self, credentials: SyftVerifyKey, token: str + ) -> Result[User | None, str]: + qks = QueryKeys(qks=[PasswordResetTokenPartitionKey.with_obj(token)]) + return self.query_one(credentials=credentials, qks=qks) + def get_by_email( self, credentials: SyftVerifyKey, email: str ) -> Result[User | None, str]: From eaad67c9ee83d596b07eb31c325670747dcd22cb Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Mon, 22 Jul 2024 10:02:43 -0300 Subject: [PATCH 06/29] Add email rate limit --- .../src/syft/protocol/protocol_version.json | 12 ++++++ .../src/syft/service/notifier/notifier.py | 7 ++-- .../syft/service/notifier/notifier_service.py | 39 ++++++++++++++++++- .../src/syft/service/user/user_service.py | 7 ++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index b375a4847ae..2dbf7c3fe09 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -15,6 +15,18 @@ "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", "action": "add" } + }, + "NotifierSettings": { + "1": { + "version": 1, + "hash": "65c8ab814d35fac32f68d3000756692592cc59940f30e3af3dcdfa2328755b9d", + "action": "remove" + }, + "2": { + "version": 2, + "hash": "2c934f54389e431347561a5340cba1709c92c4cc27de197347a01e4662a48ae5", + "action": "add" + } } } } diff --git a/packages/syft/src/syft/service/notifier/notifier.py b/packages/syft/src/syft/service/notifier/notifier.py index 26dafe34e44..ec059e26dd8 100644 --- a/packages/syft/src/syft/service/notifier/notifier.py +++ b/packages/syft/src/syft/service/notifier/notifier.py @@ -1,5 +1,3 @@ -# stdlib - # stdlib from typing import TypeVar @@ -12,6 +10,7 @@ from ...serde.serializable import serializable from ...server.credentials import SyftVerifyKey from ...types.syft_object import SYFT_OBJECT_VERSION_1 +from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject from ..context import AuthedServiceContext from ..notification.notifications import Notification @@ -126,7 +125,7 @@ class NotificationPreferences(SyftObject): @serializable() class NotifierSettings(SyftObject): __canonical_name__ = "NotifierSettings" - __version__ = SYFT_OBJECT_VERSION_1 + __version__ = SYFT_OBJECT_VERSION_2 __repr_attrs__ = [ "active", "email_enabled", @@ -154,6 +153,8 @@ class NotifierSettings(SyftObject): email_username: str | None = "" email_password: str | None = "" + email_rate_limit: dict[str, dict[SyftVerifyKey, list]] = {} + @property def email_enabled(self) -> bool: return self.notifiers_status[NOTIFIERS.EMAIL] diff --git a/packages/syft/src/syft/service/notifier/notifier_service.py b/packages/syft/src/syft/service/notifier/notifier_service.py index a201c850c22..f384ea25e41 100644 --- a/packages/syft/src/syft/service/notifier/notifier_service.py +++ b/packages/syft/src/syft/service/notifier/notifier_service.py @@ -1,4 +1,5 @@ # stdlib +from datetime import datetime import logging import traceback @@ -295,7 +296,43 @@ def dispatch_notification( notifier = notifier.ok() # If notifier is active - if notifier.active: + if notifier.active and notification.email_template is not None: + if notifier.email_rate_limit.get( + notification.email_template.__name__, None + ): + user_activity = notifier.email_rate_limit[ + notification.email_template.__name__ + ].get(notification.to_user_verify_key, None) + if user_activity is None: + notifier.email_rate_limit[notification.email_template.__name__][ + notification.to_user_verify_key, None + ] = [datetime.now(), 1] + else: + current_state = notifier.email_rate_limit[ + notification.email_template.__name__ + ][notification.to_user_verify_key] + date_refresh = abs(datetime.now() - current_state[0]).days > 1 + still_in_limit = current_state[1] < 3 + if date_refresh: + current_state[1] = 1 # Email count + current_state[0] = datetime.now() # Last time email was sent + if not date_refresh and still_in_limit: + current_state[1] += 1 # Email count + current_state[0] = datetime.now() # Last time email was sent + else: + return SyftError( + message="Couldn't send the email. You have surpassed the" + + " email threshold limit. Please try again later." + ) + else: + notifier.email_rate_limit[notification.email_template.__name__] = { + notification.to_user_verify_key: [datetime.now(), 1] + } + + result = self.stash.update(credentials=admin_key, settings=notifier) + if result.is_err(): + return SyftError(message="Couldn't update the notifier.") + resp = notifier.send_notifications( context=context, notification=notification ) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index b4395497222..475dcd2ae9c 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -120,6 +120,7 @@ def forgot_password( root_context = AuthedServiceContext(server=context.server, credentials=root_key) link = LinkedObject.with_context(user, context=root_context) notifier_service = context.server.get_service("notifierservice") + # Notifier is active notification_is_enabled = notifier_service.settings(context=root_context).active # Email is enabled @@ -154,6 +155,9 @@ def forgot_password( ) result = method(context=root_context, notification=message) + if isinstance(result, SyftError): + return result + return SyftSuccess( message="If the email is valid, we sent a password reset \ token to your email or a password request to the admin." @@ -172,6 +176,9 @@ def forgot_password( method = context.server.get_service_method(NotificationService.send) result = method(context=root_context, notification=message) + if isinstance(result, SyftError): + return result + return SyftSuccess( message="If the email is valid, we sent a password request to the admin." ) From c19f72a72c2a52bb8250401a98c878990b8a2e05 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Mon, 22 Jul 2024 10:22:50 -0300 Subject: [PATCH 07/29] Fix linting --- packages/syft/src/syft/service/notifier/notifier_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/notifier/notifier_service.py b/packages/syft/src/syft/service/notifier/notifier_service.py index f384ea25e41..55a3c50f08f 100644 --- a/packages/syft/src/syft/service/notifier/notifier_service.py +++ b/packages/syft/src/syft/service/notifier/notifier_service.py @@ -321,7 +321,7 @@ def dispatch_notification( current_state[0] = datetime.now() # Last time email was sent else: return SyftError( - message="Couldn't send the email. You have surpassed the" + message="Couldn't send the email. You have surpassed the" + " email threshold limit. Please try again later." ) else: From 1ecd67245f636a7426d487391c9fbd1c36c41807 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 23 Jul 2024 14:38:28 -0300 Subject: [PATCH 08/29] Add UserActivity Struct to track user email activity --- .../src/syft/protocol/protocol_version.json | 9 ++- .../src/syft/service/notifier/notifier.py | 12 +++- .../syft/service/notifier/notifier_enums.py | 8 +++ .../syft/service/notifier/notifier_service.py | 67 ++++++++++++++----- .../syft/service/settings/settings_service.py | 8 +++ .../src/syft/service/user/user_service.py | 6 ++ 6 files changed, 91 insertions(+), 19 deletions(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index 2dbf7c3fe09..43920df4894 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -24,7 +24,14 @@ }, "2": { "version": 2, - "hash": "2c934f54389e431347561a5340cba1709c92c4cc27de197347a01e4662a48ae5", + "hash": "be8b52597fc628d1b7cd22b776ee81416e1adbb04a45188778eb0e32ed1416b4", + "action": "add" + } + }, + "UserNotificationActivity": { + "1": { + "version": 1, + "hash": "437e9fed52cab4fec48a22450898f82eba05206f66b66d2b53f22192b7f33274", "action": "add" } } diff --git a/packages/syft/src/syft/service/notifier/notifier.py b/packages/syft/src/syft/service/notifier/notifier.py index ec059e26dd8..26f12d56608 100644 --- a/packages/syft/src/syft/service/notifier/notifier.py +++ b/packages/syft/src/syft/service/notifier/notifier.py @@ -1,4 +1,5 @@ # stdlib +from datetime import datetime from typing import TypeVar # third party @@ -30,6 +31,14 @@ def send( TBaseNotifier = TypeVar("TBaseNotifier", bound=BaseNotifier) +@serializable() +class UserNotificationActivity(SyftObject): + __canonical_name__ = "UserNotificationActivity" + __version__ = SYFT_OBJECT_VERSION_1 + count: int = 1 + date: datetime | None = None + + @serializable(canonical_name="EmailNotifier", version=1) class EmailNotifier(BaseNotifier): smtp_client: SMTPClient @@ -153,7 +162,8 @@ class NotifierSettings(SyftObject): email_username: str | None = "" email_password: str | None = "" - email_rate_limit: dict[str, dict[SyftVerifyKey, list]] = {} + email_activity: dict[str, dict[SyftVerifyKey, UserNotificationActivity]] = {} + email_rate_limit: dict[str, int] = {} @property def email_enabled(self) -> bool: diff --git a/packages/syft/src/syft/service/notifier/notifier_enums.py b/packages/syft/src/syft/service/notifier/notifier_enums.py index 023843f7d6c..cc391d3eaf0 100644 --- a/packages/syft/src/syft/service/notifier/notifier_enums.py +++ b/packages/syft/src/syft/service/notifier/notifier_enums.py @@ -6,6 +6,14 @@ from ...serde.serializable import serializable +@serializable(canonical_name="EMAIL_TYPES", version=1) +class EMAIL_TYPES(Enum): + PASSWORD_RESET_EMAIL = "PasswordResetTemplate" + ONBOARD_EMAIL = "OnBoardEmailTemplate" + REQUEST_EMAIL = "RequestEmailTemplate" + REQUEST_UPDATE_EMAIL = "RequestUpdateEmailTemplate" + + @serializable(canonical_name="NOTIFIERS", version=1) class NOTIFIERS(Enum): EMAIL = auto() diff --git a/packages/syft/src/syft/service/notifier/notifier_service.py b/packages/syft/src/syft/service/notifier/notifier_service.py index 55a3c50f08f..09e2581c222 100644 --- a/packages/syft/src/syft/service/notifier/notifier_service.py +++ b/packages/syft/src/syft/service/notifier/notifier_service.py @@ -14,12 +14,15 @@ from ...serde.serializable import serializable from ...store.document_store import DocumentStore from ..context import AuthedServiceContext +from ..notification.email_templates import PasswordResetTemplate from ..notification.notifications import Notification from ..response import SyftError from ..response import SyftSuccess from ..service import AbstractService from .notifier import NotificationPreferences from .notifier import NotifierSettings +from .notifier import UserNotificationActivity +from .notifier_enums import EMAIL_TYPES from .notifier_enums import NOTIFIERS from .notifier_stash import NotifierStash @@ -153,6 +156,10 @@ def turn_on( message="You must provide a sender email address to enable notifications." ) + # If email_rate_limit isn't defined yet. + if not notifier.email_rate_limit: + notifier.email_rate_limit = {PasswordResetTemplate.__name__: 3} + if email_sender: try: EmailStr._validate(email_sender) @@ -273,6 +280,8 @@ def init_notifier( notifier.email_sender = email_sender notifier.email_server = smtp_host notifier.email_port = smtp_port + # Default daily email rate limit per user + notifier.email_rate_limit = {PasswordResetTemplate.__name__: 3} notifier.active = True notifier_stash.set(server.signing_key.verify_key, notifier) @@ -281,6 +290,22 @@ def init_notifier( except Exception: raise Exception(f"Error initializing notifier. \n {traceback.format_exc()}") + def set_email_rate_limit( + self, context: AuthedServiceContext, email_type: EMAIL_TYPES, daily_limit: int + ) -> SyftSuccess | SyftError: + notifier = self.stash.get(context.credentials) + if notifier.is_err(): + return SyftError(message="Couldn't set the email rate limit.") + + notifier = notifier.ok() + + notifier.email_rate_limit[email_type.value] = daily_limit + result = self.stash.update(credentials=context.credentials, settings=notifier) + if result.is_err(): + return SyftError(message="Couldn't update the notifier.") + + return SyftSuccess(message="Email rate limit updated!") + # This is not a public API. # This method is used by other services to dispatch notifications internally def dispatch_notification( @@ -297,36 +322,44 @@ def dispatch_notification( notifier = notifier.ok() # If notifier is active if notifier.active and notification.email_template is not None: - if notifier.email_rate_limit.get( - notification.email_template.__name__, None - ): - user_activity = notifier.email_rate_limit[ + logging.debug("Checking user email activity") + if notifier.email_activity.get(notification.email_template.__name__, None): + user_activity = notifier.email_activity[ notification.email_template.__name__ ].get(notification.to_user_verify_key, None) + # If there's no user activity if user_activity is None: - notifier.email_rate_limit[notification.email_template.__name__][ + notifier.email_activity[notification.email_template.__name__][ notification.to_user_verify_key, None - ] = [datetime.now(), 1] - else: - current_state = notifier.email_rate_limit[ + ] = UserNotificationActivity(count=1, date=datetime.now()) + else: # If there's a previous user activity + current_state: UserNotificationActivity = notifier.email_activity[ notification.email_template.__name__ ][notification.to_user_verify_key] - date_refresh = abs(datetime.now() - current_state[0]).days > 1 - still_in_limit = current_state[1] < 3 + date_refresh = abs(datetime.now() - current_state.date).days > 1 + + limit = notifier.email_rate_limit.get( + notification.email_template.__name__, 0 + ) + still_in_limit = current_state.count < limit + # Time interval reseted. if date_refresh: - current_state[1] = 1 # Email count - current_state[0] = datetime.now() # Last time email was sent - if not date_refresh and still_in_limit: - current_state[1] += 1 # Email count - current_state[0] = datetime.now() # Last time email was sent + current_state.count = 1 + current_state.date = datetime.now() + # Time interval didn't reset yet. + elif still_in_limit or not limit: + current_state.count += 1 + current_state.date = datetime.now() else: return SyftError( message="Couldn't send the email. You have surpassed the" + " email threshold limit. Please try again later." ) else: - notifier.email_rate_limit[notification.email_template.__name__] = { - notification.to_user_verify_key: [datetime.now(), 1] + notifier.email_activity[notification.email_template.__name__] = { + notification.to_user_verify_key: UserNotificationActivity( + count=1, date=datetime.now() + ) } result = self.stash.update(credentials=admin_key, settings=notifier) diff --git a/packages/syft/src/syft/service/settings/settings_service.py b/packages/syft/src/syft/service/settings/settings_service.py index a1f92ccbdd1..d21fe17d8c6 100644 --- a/packages/syft/src/syft/service/settings/settings_service.py +++ b/packages/syft/src/syft/service/settings/settings_service.py @@ -20,6 +20,7 @@ from ...util.schema import GUEST_COMMANDS from ..context import AuthedServiceContext from ..context import UnauthedServiceContext +from ..notifier.notifier_enums import EMAIL_TYPES from ..response import SyftError from ..response import SyftSuccess from ..service import AbstractService @@ -237,6 +238,13 @@ def enable_eager_execution( message = "enabled" if enable else "disabled" return SyftSuccess(message=f"Eager execution {message}") + @service_method(path="settings.set_email_rate_limit", name="set_email_rate_limit") + def set_email_rate_limit( + self, context: AuthedServiceContext, email_type: EMAIL_TYPES, daily_limit: int + ) -> SyftSuccess | SyftError: + notifier_service = context.server.get_service("notifierservice") + return notifier_service.set_email_rate_limit(context, email_type, daily_limit) + @service_method( path="settings.allow_association_request_auto_approval", name="allow_association_request_auto_approval", diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 475dcd2ae9c..103cf18675c 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -202,6 +202,12 @@ def request_password_reset( if user is None: return SyftError(message=f"No user exists for given: {uid}") + user_role = self.get_role_for_credentials(user.verify_key) + if user_role == ServiceRole.ADMIN: + return SyftError( + message="You can't request password reset for an Admin user." + ) + user.reset_token = self.generate_new_password_reset_token() user.reset_token_date = datetime.now() From 99026e30de817e647347d7e68d6b0e7d39cc75d0 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 23 Jul 2024 14:38:28 -0300 Subject: [PATCH 09/29] Add UserActivity Struct to track user email activity --- packages/syft/src/syft/service/notifier/notifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/notifier/notifier.py b/packages/syft/src/syft/service/notifier/notifier.py index 26f12d56608..27219a271cb 100644 --- a/packages/syft/src/syft/service/notifier/notifier.py +++ b/packages/syft/src/syft/service/notifier/notifier.py @@ -36,7 +36,7 @@ class UserNotificationActivity(SyftObject): __canonical_name__ = "UserNotificationActivity" __version__ = SYFT_OBJECT_VERSION_1 count: int = 1 - date: datetime | None = None + date: datetime = datetime.now() @serializable(canonical_name="EmailNotifier", version=1) From b9913eb113d39c90702bc32dbe7ee441b921bc80 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 24 Jul 2024 16:05:04 -0300 Subject: [PATCH 10/29] Add nosec to remove false positive error --- packages/syft/src/syft/service/notifier/notifier_enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/notifier/notifier_enums.py b/packages/syft/src/syft/service/notifier/notifier_enums.py index cc391d3eaf0..c746b16a9aa 100644 --- a/packages/syft/src/syft/service/notifier/notifier_enums.py +++ b/packages/syft/src/syft/service/notifier/notifier_enums.py @@ -8,7 +8,7 @@ @serializable(canonical_name="EMAIL_TYPES", version=1) class EMAIL_TYPES(Enum): - PASSWORD_RESET_EMAIL = "PasswordResetTemplate" + PASSWORD_RESET_EMAIL = "PasswordResetTemplate" # nosec ONBOARD_EMAIL = "OnBoardEmailTemplate" REQUEST_EMAIL = "RequestEmailTemplate" REQUEST_UPDATE_EMAIL = "RequestUpdateEmailTemplate" From f8903290e93e4044878f45fa567fb9207905ac72 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 24 Jul 2024 16:09:38 -0300 Subject: [PATCH 11/29] Fix lint --- packages/syft/src/syft/service/notifier/notifier_enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/notifier/notifier_enums.py b/packages/syft/src/syft/service/notifier/notifier_enums.py index c746b16a9aa..f8c2d887ff4 100644 --- a/packages/syft/src/syft/service/notifier/notifier_enums.py +++ b/packages/syft/src/syft/service/notifier/notifier_enums.py @@ -8,7 +8,7 @@ @serializable(canonical_name="EMAIL_TYPES", version=1) class EMAIL_TYPES(Enum): - PASSWORD_RESET_EMAIL = "PasswordResetTemplate" # nosec + PASSWORD_RESET_EMAIL = "PasswordResetTemplate" # nosec ONBOARD_EMAIL = "OnBoardEmailTemplate" REQUEST_EMAIL = "RequestEmailTemplate" REQUEST_UPDATE_EMAIL = "RequestUpdateEmailTemplate" From f813e07c8f8367ded5bee8dea4d0f61c81a6dd73 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 24 Jul 2024 17:52:07 -0300 Subject: [PATCH 12/29] Add PwdResetConfig in ServerSettings --- .../service/notification/email_templates.py | 4 +- .../src/syft/service/settings/settings.py | 53 ++++++++++++++++++- .../src/syft/service/user/user_service.py | 27 +++++++--- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/syft/src/syft/service/notification/email_templates.py b/packages/syft/src/syft/service/notification/email_templates.py index bd65592d498..e35cd51677a 100644 --- a/packages/syft/src/syft/service/notification/email_templates.py +++ b/packages/syft/src/syft/service/notification/email_templates.py @@ -37,7 +37,9 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s if not user: raise Exception("User not found!") - user.reset_token = user_service.generate_new_password_reset_token() + user.reset_token = user_service.generate_new_password_reset_token( + context.server.settings.pwd_token_config + ) user.reset_token_date = datetime.now() result = user_service.stash.update( diff --git a/packages/syft/src/syft/service/settings/settings.py b/packages/syft/src/syft/service/settings/settings.py index 4c710e354a6..80b9982e9f5 100644 --- a/packages/syft/src/syft/service/settings/settings.py +++ b/packages/syft/src/syft/service/settings/settings.py @@ -2,6 +2,11 @@ import logging from typing import Any +# third party +from pydantic import field_validator +from pydantic import model_validator +from typing_extensions import Self + # relative from ...abstract_server import ServerSideType from ...abstract_server import ServerType @@ -10,6 +15,7 @@ from ...service.worker.utils import DEFAULT_WORKER_POOL_NAME from ...types.syft_object import PartialSyftObject from ...types.syft_object import SYFT_OBJECT_VERSION_1 +from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject from ...types.uid import UID from ...util import options @@ -22,7 +28,32 @@ @serializable() -class ServerSettingsUpdate(PartialSyftObject): +class PwdTokenResetConfig(SyftObject): + __canonical_name__ = "PwdTokenResetConfig" + __version__ = SYFT_OBJECT_VERSION_1 + ascii: bool = True + numbers: bool = True + token_len: int = 12 + + @model_validator(mode="after") + def validate_char_types(self) -> Self: + if not self.ascii and not self.numbers: + raise ValueError( + "Invalid config, at least one of the ascii/number options must be true." + ) + + return self + + @field_validator("token_len") + @classmethod + def check_token_len(cls, value: int) -> int: + if value < 4: + raise ValueError("Token length must be greater than 4.") + return value + + +@serializable() +class ServerSettingsUpdateV1(PartialSyftObject): __canonical_name__ = "ServerSettingsUpdate" __version__ = SYFT_OBJECT_VERSION_1 id: UID @@ -37,10 +68,27 @@ class ServerSettingsUpdate(PartialSyftObject): eager_execution_enabled: bool = False +@serializable() +class ServerSettingsUpdate(PartialSyftObject): + __canonical_name__ = "ServerSettingsUpdate" + __version__ = SYFT_OBJECT_VERSION_2 + id: UID + name: str + organization: str + description: str + on_board: bool + signup_enabled: bool + admin_email: str + association_request_auto_approval: bool + welcome_markdown: HTMLObject | MarkdownDescription + eager_execution_enabled: bool = False + pwd_token_config: PwdTokenResetConfig + + @serializable() class ServerSettings(SyftObject): __canonical_name__ = "ServerSettings" - __version__ = SYFT_OBJECT_VERSION_1 + __version__ = SYFT_OBJECT_VERSION_2 __repr_attrs__ = [ "name", "organization", @@ -65,6 +113,7 @@ class ServerSettings(SyftObject): association_request_auto_approval: bool eager_execution_enabled: bool = False default_worker_pool: str = DEFAULT_WORKER_POOL_NAME + pwd_token_config: PwdTokenResetConfig = PwdTokenResetConfig() welcome_markdown: HTMLObject | MarkdownDescription = HTMLObject( text=DEFAULT_WELCOME_MSG ) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 103cf18675c..3d722938e5d 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -31,6 +31,7 @@ from ..service import SERVICE_TO_TYPES from ..service import TYPE_TO_SERVICE from ..service import service_method +from ..settings.settings import PwdTokenResetConfig from ..settings.settings_stash import SettingsStash from .user import User from .user import UserCreate @@ -120,11 +121,11 @@ def forgot_password( root_context = AuthedServiceContext(server=context.server, credentials=root_key) link = LinkedObject.with_context(user, context=root_context) notifier_service = context.server.get_service("notifierservice") - # Notifier is active - notification_is_enabled = notifier_service.settings(context=root_context).active + notifier = notifier_service.settings(context=root_context) + notification_is_enabled = notifier.active # Email is enabled - email_is_enabled = notifier_service.settings(context=root_context).email_enabled + email_is_enabled = notifier.email_enabled # User Preferences allow email notification user_allow_email_notifications = user.notifications_enabled[NOTIFIERS.EMAIL] @@ -208,7 +209,9 @@ def request_password_reset( message="You can't request password reset for an Admin user." ) - user.reset_token = self.generate_new_password_reset_token() + user.reset_token = self.generate_new_password_reset_token( + context.server.settings.pwd_token_config + ) user.reset_token_date = datetime.now() result = self.stash.update( @@ -276,12 +279,20 @@ def reset_password( ) return SyftSuccess(message="User Password updated successfully!") - def generate_new_password_reset_token(self) -> str: - token_len = 12 - valid_characters = string.ascii_letters + string.digits + def generate_new_password_reset_token( + self, token_config: PwdTokenResetConfig + ) -> str: + valid_characters = "" + if token_config.ascii: + valid_characters += string.ascii_letters + + if token_config.numbers: + valid_characters += string.digits + + # valid_characters = string.ascii_letters + string.digits generated_token = "".join( - secrets.choice(valid_characters) for _ in range(token_len) + secrets.choice(valid_characters) for _ in range(token_config.token_len) ) return generated_token From a6af2e6ce3a91ca2cb5e843c169b067cc601be9a Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 24 Jul 2024 19:09:50 -0300 Subject: [PATCH 13/29] Fix setup update permission issue/Fix protocol versions --- .../src/syft/protocol/protocol_version.json | 39 ++++++++++++++----- .../syft/service/settings/settings_service.py | 7 +++- packages/syft/src/syft/service/user/user.py | 34 ++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index 43920df4894..f31ffbb0ad6 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -4,15 +4,10 @@ }, "dev": { "object_versions": { - "User": { + "UserNotificationActivity": { "1": { "version": 1, - "hash": "2df4b68182c558dba5485a8a6867acf2a5c341b249ad67373a504098aa8c4343", - "action": "remove" - }, - "2": { - "version": 2, - "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", + "hash": "422fd01c6d9af38688a9982abd34e80794a1f6ddd444cca225d77f49189847a9", "action": "add" } }, @@ -28,10 +23,36 @@ "action": "add" } }, - "UserNotificationActivity": { + "PwdTokenResetConfig": { "1": { "version": 1, - "hash": "437e9fed52cab4fec48a22450898f82eba05206f66b66d2b53f22192b7f33274", + "hash": "88f1f450d5ef7b00acc179ed75f3d01ebb4f206ff47d0bb8f45a98f1ff42cba2", + "action": "add" + } + }, + "ServerSettingsUpdate": { + "2": { + "version": 2, + "hash": "7ccd479b2ba7ef042de0dfe3c2110ac301f73e062a6cfa4ab2ec55d1ab93aaf0", + "action": "add" + } + }, + "ServerSettings": { + "1": { + "version": 1, + "hash": "5a1e7470cbeaaae5b80ac9beecb743734f7e4e42d429a09ea8defa569a5ddff1", + "action": "remove" + }, + "2": { + "version": 2, + "hash": "b68b9c62d3417029fcaab7f22f77fa9d0d339f38c8d64644af681af0a55f1ec5", + "action": "add" + } + }, + "User": { + "2": { + "version": 2, + "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", "action": "add" } } diff --git a/packages/syft/src/syft/service/settings/settings_service.py b/packages/syft/src/syft/service/settings/settings_service.py index d21fe17d8c6..f41114dcbb9 100644 --- a/packages/syft/src/syft/service/settings/settings_service.py +++ b/packages/syft/src/syft/service/settings/settings_service.py @@ -69,7 +69,12 @@ def set( else: return SyftError(message=result.err()) - @service_method(path="settings.update", name="update", autosplat=["settings"]) + @service_method( + path="settings.update", + name="update", + autosplat=["settings"], + roles=ADMIN_ROLE_LEVEL, + ) def update( self, context: AuthedServiceContext, settings: ServerSettingsUpdate ) -> Result[SyftSuccess, SyftError]: diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index 231b4a8f00a..48e60f87382 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -37,6 +37,40 @@ from .user_roles import ServiceRole +@serializable() +class UserV1(SyftObject): + # version + __canonical_name__ = "User" + __version__ = SYFT_OBJECT_VERSION_1 + + id: UID | None = None # type: ignore[assignment] + + # fields + notifications_enabled: dict[NOTIFIERS, bool] = { + NOTIFIERS.EMAIL: True, + NOTIFIERS.SMS: False, + NOTIFIERS.SLACK: False, + NOTIFIERS.APP: False, + } + email: EmailStr | None = None + name: str | None = None + hashed_password: str | None = None + salt: str | None = None + signing_key: SyftSigningKey | None = None + verify_key: SyftVerifyKey | None = None + role: ServiceRole | None = None + institution: str | None = None + website: str | None = None + created_at: str | None = None + # TODO where do we put this flag? + mock_execution_permission: bool = False + + # serde / storage rules + __attr_searchable__ = ["name", "email", "verify_key", "role"] + __attr_unique__ = ["email", "signing_key", "verify_key"] + __repr_attrs__ = ["name", "email"] + + @serializable() class User(SyftObject): # version From 0b8980c9321a72485bf1f1c3fb390794e1e5d707 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Thu, 25 Jul 2024 09:53:10 -0300 Subject: [PATCH 14/29] Replace absolute imports --- packages/syft/src/syft/service/user/user.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index 7edf43e20d1..9177e167ccf 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -13,15 +13,13 @@ from pydantic import ValidationError from pydantic import field_validator -# syft absolute -from syft.types.syft_migration import migrate - # relative from ...client.api import APIRegistry from ...serde.serializable import serializable from ...server.credentials import SyftSigningKey from ...server.credentials import SyftVerifyKey from ...types.syft_metaclass import Empty +from ...types.syft_migration import migrate from ...types.syft_object import PartialSyftObject from ...types.syft_object import SYFT_OBJECT_VERSION_1 from ...types.syft_object import SYFT_OBJECT_VERSION_2 From 74f1b9da8124b0468a2c59fca716d17e103f8916 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Fri, 26 Jul 2024 10:40:58 -0300 Subject: [PATCH 15/29] Fix pending issues --- .../service/notification/email_templates.py | 7 ++- .../src/syft/service/notifier/notifier.py | 46 +++++++++++++++++ .../src/syft/service/settings/settings.py | 50 ------------------- packages/syft/src/syft/service/user/user.py | 13 ++++- .../src/syft/service/user/user_service.py | 20 +++----- 5 files changed, 70 insertions(+), 66 deletions(-) diff --git a/packages/syft/src/syft/service/notification/email_templates.py b/packages/syft/src/syft/service/notification/email_templates.py index e35cd51677a..fec2810b02a 100644 --- a/packages/syft/src/syft/service/notification/email_templates.py +++ b/packages/syft/src/syft/service/notification/email_templates.py @@ -72,7 +72,6 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s p { font-size: 16px; line-height: 1.5; - text-align: center; } .button { display: block; @@ -96,9 +95,13 @@ def email_body(notification: "Notification", context: AuthedServiceContext) -> s body = f"""

Password Reset

-

Hello,

We received a request to reset your password. Your new temporary token is:

{user.reset_token}

+

Use + + syft_client.reset_password(token='{user.reset_token}', new_password=*****) + . + to reset your password.

If you didn't request a password reset, please ignore this email.

""" diff --git a/packages/syft/src/syft/service/notifier/notifier.py b/packages/syft/src/syft/service/notifier/notifier.py index 19934327ed0..d9cdef82c2c 100644 --- a/packages/syft/src/syft/service/notifier/notifier.py +++ b/packages/syft/src/syft/service/notifier/notifier.py @@ -7,6 +7,7 @@ # 2) .....user_settings().x # stdlib +from collections.abc import Callable from datetime import datetime from typing import TypeVar @@ -18,9 +19,12 @@ # relative from ...serde.serializable import serializable from ...server.credentials import SyftVerifyKey +from ...types.syft_migration import migrate from ...types.syft_object import SYFT_OBJECT_VERSION_1 from ...types.syft_object import SYFT_OBJECT_VERSION_2 from ...types.syft_object import SyftObject +from ...types.transforms import drop +from ...types.transforms import make_set_default from ..context import AuthedServiceContext from ..notification.notifications import Notification from ..response import SyftError @@ -139,6 +143,34 @@ class NotificationPreferences(SyftObject): app: bool = False +@serializable() +class NotifierSettingsV1(SyftObject): + __canonical_name__ = "NotifierSettings" + __version__ = SYFT_OBJECT_VERSION_1 + __repr_attrs__ = [ + "active", + "email_enabled", + ] + active: bool = False + + notifiers: dict[NOTIFIERS, type[TBaseNotifier]] = { + NOTIFIERS.EMAIL: EmailNotifier, + } + + notifiers_status: dict[NOTIFIERS, bool] = { + NOTIFIERS.EMAIL: True, + NOTIFIERS.SMS: False, + NOTIFIERS.SLACK: False, + NOTIFIERS.APP: False, + } + + email_sender: str | None = "" + email_server: str | None = "" + email_port: int | None = 587 + email_username: str | None = "" + email_password: str | None = "" + + @serializable() class NotifierSettings(SyftObject): __canonical_name__ = "NotifierSettings" @@ -249,3 +281,17 @@ def select_notifiers(self, notification: Notification) -> list[BaseNotifier]: notifier_objs.append(self.notifiers[notifier_type]()) # type: ignore[misc] return notifier_objs + + +@migrate(NotifierSettingsV1, NotifierSettings) +def migrate_server_settings_v1_to_current() -> list[Callable]: + return [ + make_set_default("email_activity", {}), + make_set_default("email_rate_limit", {}), + ] + + +@migrate(NotifierSettings, NotifierSettingsV1) +def migrate_server_settings_v2_to_v1() -> list[Callable]: + # Use drop function on "notifications_enabled" attrubute + return [drop(["email_activity"]), drop(["email_rate_limit"])] diff --git a/packages/syft/src/syft/service/settings/settings.py b/packages/syft/src/syft/service/settings/settings.py index 16634b20acc..4ed29c7fd40 100644 --- a/packages/syft/src/syft/service/settings/settings.py +++ b/packages/syft/src/syft/service/settings/settings.py @@ -174,56 +174,6 @@ class ServerSettingsV2(SyftObject): ) notifications_enabled: bool - def _repr_html_(self) -> Any: - # .api.services.notifications.settings() is how the server itself would dispatch notifications. - # .api.services.notifications.user_settings() sets if a specific user wants or not to receive notifications. - # Class NotifierSettings holds both pieces of info. - # Users will get notification x where x in {email, slack, sms, app} if three things are set to True: - # 1) .....settings().active - # 2) .....settings().x_enabled - # 3) .....user_settings().x - - preferences = self._get_api().services.notifications.settings() - if not preferences: - notification_print_str = "Create notification settings using enable_notifications from user_service" - else: - notifications = [] - if preferences.email_enabled: - notifications.append("email") - if preferences.sms_enabled: - notifications.append("sms") - if preferences.slack_enabled: - notifications.append("slack") - if preferences.app_enabled: - notifications.append("app") - - # self.notifications_enabled = preferences.active - if preferences.active: - if notifications: - notification_print_str = f"Enabled via {', '.join(notifications)}" - else: - notification_print_str = "Enabled without any communication method" - else: - notification_print_str = "Disabled" - - return f""" - -
-

Settings

-

Id: {self.id}

-

Name: {self.name}

-

Organization: {self.organization}

-

Description: {self.description}

-

Deployed on: {self.deployed_on}

-

Signup enabled: {self.signup_enabled}

-

Notifications enabled: {notification_print_str}

-

Admin email: {self.admin_email}

-
- - """ - @serializable() class ServerSettings(SyftObject): diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index 9177e167ccf..b37464d6c7a 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -112,12 +112,23 @@ def migrate_server_user_update_v1_current() -> list[Callable]: return [ make_set_default("reset_token", None), make_set_default("reset_token_date", None), + make_set_default( + "__attr_searchable__", + ["name", "email", "verify_key", "role", "reset_token"], + ), ] @migrate(User, UserV1) def migrate_server_user_downgrade_current_v1() -> list[Callable]: - return [drop("reset_token"), drop("reset_token_date")] + return [ + drop("reset_token"), + drop("reset_token_date"), + drop("__attr_searchable__"), + make_set_default( + "__attr_searchable__", ["name", "email", "verify_key", "role"] + ), + ] def default_role(role: ServiceRole) -> Callable: diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 3d722938e5d..9ea91ca4ee1 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -97,13 +97,14 @@ def create( def forgot_password( self, context: AuthedServiceContext, email: str ) -> SyftSuccess | SyftError: + success_msg = ( + "If the email is valid, we sent a password " + + "reset token to your email or a password request to the admin." + ) result = self.stash.get_by_email(credentials=context.credentials, email=email) # Isn't a valid email if result.is_err(): - return SyftSuccess( - message="If the email is valid, we sent a password \ - reset token to your email or a password request to the admin." - ) + return SyftSuccess(message=success_msg) user = result.ok() user_role = self.get_role_for_credentials(user.verify_key) @@ -159,10 +160,7 @@ def forgot_password( if isinstance(result, SyftError): return result - return SyftSuccess( - message="If the email is valid, we sent a password reset \ - token to your email or a password request to the admin." - ) + return SyftSuccess(message=success_msg) # Email notification is Enabled # Therefore, we can directly send a message to the user with its new password. @@ -180,9 +178,7 @@ def forgot_password( if isinstance(result, SyftError): return result - return SyftSuccess( - message="If the email is valid, we sent a password request to the admin." - ) + return SyftSuccess(message=success_msg) @service_method( path="user.request_password_reset", @@ -289,8 +285,6 @@ def generate_new_password_reset_token( if token_config.numbers: valid_characters += string.digits - # valid_characters = string.ascii_letters + string.digits - generated_token = "".join( secrets.choice(valid_characters) for _ in range(token_config.token_len) ) From 886b1fc76d8dbd90f4003bb02b9d370403581574 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Fri, 26 Jul 2024 10:54:49 -0300 Subject: [PATCH 16/29] Add token exp as a PwdResetToken config option --- .../src/syft/protocol/protocol_version.json | 66 ++----------------- .../src/syft/service/settings/settings.py | 1 + .../src/syft/service/user/user_service.py | 3 +- 3 files changed, 7 insertions(+), 63 deletions(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index 4ce7a814058..78dae434fe2 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -1,66 +1,8 @@ { + "1": { + "release_name": "0.8.7.json" + }, "2": { "release_name": "0.8.8.json" - }, - "dev": { - "object_versions": { - "UserNotificationActivity": { - "1": { - "version": 1, - "hash": "422fd01c6d9af38688a9982abd34e80794a1f6ddd444cca225d77f49189847a9", - "action": "add" - } - }, - "NotifierSettings": { - "1": { - "version": 1, - "hash": "65c8ab814d35fac32f68d3000756692592cc59940f30e3af3dcdfa2328755b9d", - "action": "remove" - }, - "2": { - "version": 2, - "hash": "be8b52597fc628d1b7cd22b776ee81416e1adbb04a45188778eb0e32ed1416b4", - "action": "add" - } - }, - "PwdTokenResetConfig": { - "1": { - "version": 1, - "hash": "88f1f450d5ef7b00acc179ed75f3d01ebb4f206ff47d0bb8f45a98f1ff42cba2", - "action": "add" - } - }, - "ServerSettingsUpdate": { - "2": { - "version": 2, - "hash": "23b2716e9dceca667e228408e2416c82f11821e322e5bccf1f83406f3d09abdc", - "action": "add" - }, - "3": { - "version": 3, - "hash": "335c7946f2e52d09c7b26f511120cd340717c74c5cca9107e84f839da993c55c", - "action": "add" - } - }, - "User": { - "2": { - "version": 2, - "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", - "action": "add" - } - }, - "ServerSettings": { - "2": { - "version": 2, - "hash": "7727ea54e494dc9deaa0d1bd38ac8a6180bc192b74eec5659adbc338a19e21f5", - "action": "add" - }, - "3": { - "version": 3, - "hash": "997667e1cba22d151857aacc2caba6b1ca73c1648adbd03461dc74a0c0c372b3", - "action": "add" - } - } - } } -} +} diff --git a/packages/syft/src/syft/service/settings/settings.py b/packages/syft/src/syft/service/settings/settings.py index 4ed29c7fd40..67720658c80 100644 --- a/packages/syft/src/syft/service/settings/settings.py +++ b/packages/syft/src/syft/service/settings/settings.py @@ -39,6 +39,7 @@ class PwdTokenResetConfig(SyftObject): ascii: bool = True numbers: bool = True token_len: int = 12 + token_exp_min: int = 30 @model_validator(mode="after") def validate_char_types(self) -> Self: diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 9ea91ca4ee1..0dd2aade952 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -249,7 +249,8 @@ def reset_password( time_difference = now - user.reset_token_date # If token expired - if time_difference > timedelta(minutes=30): + expiration_time = context.server.settings.pwd_token_config.token_exp_min + if time_difference > timedelta(minutes=expiration_time): return invalid_token_error if not validate_password(new_password): From d745a9c60ceedcc53aaae4611c919786877b65f6 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Fri, 26 Jul 2024 11:31:27 -0300 Subject: [PATCH 17/29] Fix linting --- packages/syft/src/syft/protocol/protocol_version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index 78dae434fe2..0eb298b0648 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -5,4 +5,4 @@ "2": { "release_name": "0.8.8.json" } -} +} From 43220215f24f0fc8b1b174ef28d5a26c3973dfda Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 30 Jul 2024 10:15:50 -0300 Subject: [PATCH 18/29] Improve code readability --- .../src/syft/service/user/user_service.py | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 0dd2aade952..06014015c57 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -159,24 +159,23 @@ def forgot_password( result = method(context=root_context, notification=message) if isinstance(result, SyftError): return result + else: + # Email notification is Enabled + # Therefore, we can directly send a message to the + # user with its new password. + message = CreateNotification( + subject="You requested a password reset.", + from_user_verify_key=root_key, + to_user_verify_key=user.verify_key, + linked_obj=link, + notifier_types=[NOTIFIERS.EMAIL], + email_template=PasswordResetTemplate, + ) - return SyftSuccess(message=success_msg) - - # Email notification is Enabled - # Therefore, we can directly send a message to the user with its new password. - message = CreateNotification( - subject="You requested a password reset.", - from_user_verify_key=root_key, - to_user_verify_key=user.verify_key, - linked_obj=link, - notifier_types=[NOTIFIERS.EMAIL], - email_template=PasswordResetTemplate, - ) - - method = context.server.get_service_method(NotificationService.send) - result = method(context=root_context, notification=message) - if isinstance(result, SyftError): - return result + method = context.server.get_service_method(NotificationService.send) + result = method(context=root_context, notification=message) + if isinstance(result, SyftError): + return result return SyftSuccess(message=success_msg) From 89ee38798555fbdebe840662507cb957cad2abc6 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Tue, 30 Jul 2024 15:34:29 -0300 Subject: [PATCH 19/29] ADD protocol version --- .../src/syft/protocol/protocol_version.json | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/syft/src/syft/protocol/protocol_version.json b/packages/syft/src/syft/protocol/protocol_version.json index 0eb298b0648..9cf7d42efd0 100644 --- a/packages/syft/src/syft/protocol/protocol_version.json +++ b/packages/syft/src/syft/protocol/protocol_version.json @@ -4,5 +4,51 @@ }, "2": { "release_name": "0.8.8.json" + }, + "dev": { + "object_versions": { + "User": { + "2": { + "version": 2, + "hash": "af6fb5b2e1606e97838f4a60f0536ad95db606d455e94acbd1977df866608a2c", + "action": "add" + } + }, + "UserNotificationActivity": { + "1": { + "version": 1, + "hash": "422fd01c6d9af38688a9982abd34e80794a1f6ddd444cca225d77f49189847a9", + "action": "add" + } + }, + "NotifierSettings": { + "2": { + "version": 2, + "hash": "be8b52597fc628d1b7cd22b776ee81416e1adbb04a45188778eb0e32ed1416b4", + "action": "add" + } + }, + "PwdTokenResetConfig": { + "1": { + "version": 1, + "hash": "0415a272428f22add4896c64aa9f29c8c1d35619e2433da6564eb5f1faff39ac", + "action": "add" + } + }, + "ServerSettingsUpdate": { + "3": { + "version": 3, + "hash": "335c7946f2e52d09c7b26f511120cd340717c74c5cca9107e84f839da993c55c", + "action": "add" + } + }, + "ServerSettings": { + "3": { + "version": 3, + "hash": "997667e1cba22d151857aacc2caba6b1ca73c1648adbd03461dc74a0c0c372b3", + "action": "add" + } + } + } } } From 7594e8514e521afe173c1f9c0c18c7e516699746 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 11:16:02 -0300 Subject: [PATCH 20/29] Update syft version for prepare migration data CI --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 5fd5cf7fc6f..4e5ae2d2001 100644 --- a/tox.ini +++ b/tox.ini @@ -1078,7 +1078,7 @@ commands = description = Prepare Migration Data pip_pre = True deps = - syft==0.8.7 + syft==0.8.8 nbmake allowlist_externals = bash From 30584e2bc0da19d472403c496b2ca60edda9b57e Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 12:18:28 -0300 Subject: [PATCH 21/29] Fix migration tests --- packages/syft/src/syft/service/user/user.py | 23 +++++++++++++++++++ .../src/syft/types/syft_object_registry.py | 5 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index b37464d6c7a..be3fe07bba0 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -395,6 +395,24 @@ def user_create_to_user() -> list[Callable]: ] +@transform(UserV1, UserView) +def userv1_to_view_user() -> list[Callable]: + return [ + keep( + [ + "id", + "email", + "name", + "role", + "institution", + "website", + "mock_execution_permission", + "notifications_enabled", + ] + ) + ] + + @transform(User, UserView) def user_to_view_user() -> list[Callable]: return [ @@ -423,6 +441,11 @@ class UserPrivateKey(SyftObject): role: ServiceRole +@transform(UserV1, UserPrivateKey) +def userv1_to_user_verify() -> list[Callable]: + return [keep(["email", "signing_key", "id", "role"])] + + @transform(User, UserPrivateKey) def user_to_user_verify() -> list[Callable]: return [keep(["email", "signing_key", "id", "role"])] diff --git a/packages/syft/src/syft/types/syft_object_registry.py b/packages/syft/src/syft/types/syft_object_registry.py index d5cc342635e..3d0548f6cf1 100644 --- a/packages/syft/src/syft/types/syft_object_registry.py +++ b/packages/syft/src/syft/types/syft_object_registry.py @@ -131,7 +131,10 @@ def get_transform( klass_from = type_from_mro.__name__ version_from = None for type_to_mro in type_to.mro(): - if issubclass(type_to_mro, SyftBaseObject): + if ( + issubclass(type_to_mro, SyftBaseObject) + and type_to_mro != SyftBaseObject + ): klass_to = type_to_mro.__canonical_name__ version_to = type_to_mro.__version__ else: From 470cae83db0c59070c5ebbeef48f4d8d8a76a24c Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 13:26:52 -0300 Subject: [PATCH 22/29] Fix user transform --- packages/syft/src/syft/service/user/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/syft/src/syft/service/user/user.py b/packages/syft/src/syft/service/user/user.py index be3fe07bba0..cb9b8860c03 100644 --- a/packages/syft/src/syft/service/user/user.py +++ b/packages/syft/src/syft/service/user/user.py @@ -112,6 +112,7 @@ def migrate_server_user_update_v1_current() -> list[Callable]: return [ make_set_default("reset_token", None), make_set_default("reset_token_date", None), + drop("__attr_searchable__"), make_set_default( "__attr_searchable__", ["name", "email", "verify_key", "role", "reset_token"], From e3529e3072c165596d3a3ce10902e0f8659e8cf8 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 15:22:41 -0300 Subject: [PATCH 23/29] ADD test notebook --- .../api/0.8/13-forgot-user-password.ipynb | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 notebooks/api/0.8/13-forgot-user-password.ipynb diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb new file mode 100644 index 00000000000..dc778a92f7e --- /dev/null +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -0,0 +1,170 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Forgot User Password" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "## Initialize the server" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "# stdlib\n", + "\n", + "# syft absolute\n", + "import syft as sy\n", + "from syft import SyftError\n", + "from syft import SyftSuccess\n", + "\n", + "server = sy.orchestra.launch(\n", + " name=\"test-datasite-1\",\n", + " dev_mode=True,\n", + " create_producer=True,\n", + " n_consumers=3,\n", + " reset=True,\n", + " port=8081,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## Register a new user" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "datasite_client = server.login(email=\"info@openmined.org\", password=\"changethis\")\n", + "datasite_client.register(\n", + " email=\"user@openmined.org\",\n", + " password=\"verysecurepassword\",\n", + " password_verify=\"verysecurepassword\",\n", + " name=\"New User\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "### Ask for a password reset - Notifier disabled Workflow" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "### Call for users.forgot_password" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "guest_client = server.login_as_guest()\n", + "res = guest_client.users.forgot_password(email=\"user@openmined.org\")\n", + "\n", + "assert isinstance(res, SyftSuccess)" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Admin generates a temp token" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "temp_token = datasite_client.users.request_password_reset(\n", + " datasite_client.notifications[0].linked_obj.resolve.id\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "### User use this token to reset password" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "res = guest_client.users.reset_password(token=temp_token, new_password=\"Password123\")\n", + "\n", + "assert isinstance(res, SyftSuccess)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "new_user_session = server.login(email=\"user@openmined.org\", password=\"Password123\")\n", + "\n", + "assert not isinstance(new_user_session, SyftError)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 200829966dc0360d95ddfcf43159c71153e4c5d8 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 15:48:45 -0300 Subject: [PATCH 24/29] Set a fix number of rounds --- packages/syft/src/syft/service/user/user_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/syft/src/syft/service/user/user_service.py b/packages/syft/src/syft/service/user/user_service.py index 06014015c57..336f94b5e42 100644 --- a/packages/syft/src/syft/service/user/user_service.py +++ b/packages/syft/src/syft/service/user/user_service.py @@ -259,7 +259,7 @@ def reset_password( and at least one number." ) - salt, hashed = salt_and_hash_password(new_password, len(new_password)) + salt, hashed = salt_and_hash_password(new_password, 12) user.hashed_password = hashed user.salt = salt From a51d8821d8dc121c11244546916640f23510f09b Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 16:29:55 -0300 Subject: [PATCH 25/29] Update test notebook --- notebooks/api/0.8/13-forgot-user-password.ipynb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb index dc778a92f7e..960a046d6e5 100644 --- a/notebooks/api/0.8/13-forgot-user-password.ipynb +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -128,9 +128,7 @@ "metadata": {}, "outputs": [], "source": [ - "res = guest_client.users.reset_password(token=temp_token, new_password=\"Password123\")\n", - "\n", - "assert isinstance(res, SyftSuccess)" + "guest_client.users.reset_password(token=temp_token, new_password=\"Password123\")" ] }, { From 98f650c1721b857814883c2805f08c35796ffeeb Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 16:42:25 -0300 Subject: [PATCH 26/29] Update notebook to show the error message --- notebooks/api/0.8/13-forgot-user-password.ipynb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb index 960a046d6e5..afb8e091c4c 100644 --- a/notebooks/api/0.8/13-forgot-user-password.ipynb +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -27,7 +27,6 @@ "\n", "# syft absolute\n", "import syft as sy\n", - "from syft import SyftError\n", "from syft import SyftSuccess\n", "\n", "server = sy.orchestra.launch(\n", @@ -128,7 +127,10 @@ "metadata": {}, "outputs": [], "source": [ - "guest_client.users.reset_password(token=temp_token, new_password=\"Password123\")" + "res = guest_client.users.reset_password(token=temp_token, new_password=\"Password123\")\n", + "\n", + "if not isinstance(res, SyftSuccess):\n", + " raise Exception(f\"Res isn't SyftSuccess, its {res}\")" ] }, { @@ -140,7 +142,8 @@ "source": [ "new_user_session = server.login(email=\"user@openmined.org\", password=\"Password123\")\n", "\n", - "assert not isinstance(new_user_session, SyftError)" + "if not isinstance(new_user_session, SyftSuccess):\n", + " raise Exception(f\"Res isn't SyftSuccess, its {new_user_session}\")" ] } ], From 11fe2741518b7da36062006156f887573f9f2c2b Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 16:52:10 -0300 Subject: [PATCH 27/29] Update notebook to show the error message --- notebooks/api/0.8/13-forgot-user-password.ipynb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb index afb8e091c4c..91b7ef69427 100644 --- a/notebooks/api/0.8/13-forgot-user-password.ipynb +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -89,7 +89,8 @@ "guest_client = server.login_as_guest()\n", "res = guest_client.users.forgot_password(email=\"user@openmined.org\")\n", "\n", - "assert isinstance(res, SyftSuccess)" + "if not isinstance(res, SyftSuccess):\n", + " raise Exception(f\"Res isn't SyftSuccess, its {res}\")" ] }, { @@ -109,7 +110,10 @@ "source": [ "temp_token = datasite_client.users.request_password_reset(\n", " datasite_client.notifications[0].linked_obj.resolve.id\n", - ")" + ")\n", + "\n", + "if not isinstance(temp_token, str):\n", + " raise Exception(f\"temp_token isn't a string, its {res}\")" ] }, { From f203c63e21ecb86b51ef9e0fbe0675b437544da1 Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 17:05:44 -0300 Subject: [PATCH 28/29] Update notebook to show the error message --- .../api/0.8/13-forgot-user-password.ipynb | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb index 91b7ef69427..4d7f6a96426 100644 --- a/notebooks/api/0.8/13-forgot-user-password.ipynb +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -94,9 +94,19 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "8", "metadata": {}, + "outputs": [], + "source": [ + "res" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, "source": [ "### Admin generates a temp token" ] @@ -104,7 +114,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -113,12 +123,12 @@ ")\n", "\n", "if not isinstance(temp_token, str):\n", - " raise Exception(f\"temp_token isn't a string, its {res}\")" + " raise Exception(f\"temp_token isn't a string, its {temp_token}\")" ] }, { "cell_type": "markdown", - "id": "10", + "id": "11", "metadata": {}, "source": [ "### User use this token to reset password" @@ -127,7 +137,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -140,7 +150,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "13", "metadata": {}, "outputs": [], "source": [ From c19853e9cb3af1e5836d75ce063d31b8ee89f90e Mon Sep 17 00:00:00 2001 From: IonesioJunior Date: Wed, 31 Jul 2024 17:31:05 -0300 Subject: [PATCH 29/29] Update test notebook to get the latest notification --- .../api/0.8/13-forgot-user-password.ipynb | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/notebooks/api/0.8/13-forgot-user-password.ipynb b/notebooks/api/0.8/13-forgot-user-password.ipynb index 4d7f6a96426..8ad3cdf0918 100644 --- a/notebooks/api/0.8/13-forgot-user-password.ipynb +++ b/notebooks/api/0.8/13-forgot-user-password.ipynb @@ -27,6 +27,7 @@ "\n", "# syft absolute\n", "import syft as sy\n", + "from syft import SyftError\n", "from syft import SyftSuccess\n", "\n", "server = sy.orchestra.launch(\n", @@ -55,12 +56,15 @@ "outputs": [], "source": [ "datasite_client = server.login(email=\"info@openmined.org\", password=\"changethis\")\n", - "datasite_client.register(\n", - " email=\"user@openmined.org\",\n", + "res = datasite_client.register(\n", + " email=\"new_syft_user@openmined.org\",\n", " password=\"verysecurepassword\",\n", " password_verify=\"verysecurepassword\",\n", " name=\"New User\",\n", - ")" + ")\n", + "\n", + "if not isinstance(res, SyftSuccess):\n", + " raise Exception(f\"Res isn't SyftSuccess, its {res}\")" ] }, { @@ -87,25 +91,15 @@ "outputs": [], "source": [ "guest_client = server.login_as_guest()\n", - "res = guest_client.users.forgot_password(email=\"user@openmined.org\")\n", + "res = guest_client.users.forgot_password(email=\"new_syft_user@openmined.org\")\n", "\n", "if not isinstance(res, SyftSuccess):\n", " raise Exception(f\"Res isn't SyftSuccess, its {res}\")" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "res" - ] - }, { "cell_type": "markdown", - "id": "9", + "id": "8", "metadata": {}, "source": [ "### Admin generates a temp token" @@ -114,12 +108,12 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "9", "metadata": {}, "outputs": [], "source": [ "temp_token = datasite_client.users.request_password_reset(\n", - " datasite_client.notifications[0].linked_obj.resolve.id\n", + " datasite_client.notifications[-1].linked_obj.resolve.id\n", ")\n", "\n", "if not isinstance(temp_token, str):\n", @@ -128,7 +122,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "10", "metadata": {}, "source": [ "### User use this token to reset password" @@ -137,7 +131,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "11", "metadata": {}, "outputs": [], "source": [ @@ -150,13 +144,15 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "12", "metadata": {}, "outputs": [], "source": [ - "new_user_session = server.login(email=\"user@openmined.org\", password=\"Password123\")\n", + "new_user_session = server.login(\n", + " email=\"new_syft_user@openmined.org\", password=\"Password123\"\n", + ")\n", "\n", - "if not isinstance(new_user_session, SyftSuccess):\n", + "if isinstance(new_user_session, SyftError):\n", " raise Exception(f\"Res isn't SyftSuccess, its {new_user_session}\")" ] }