amsdal 0.4.10__cp312-cp312-macosx_10_13_universal2.whl → 0.5.33__cp312-cp312-macosx_10_13_universal2.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +28 -0
  2. amsdal/__about__.py +1 -1
  3. amsdal/__migrations__/0000_initial.py +22 -203
  4. amsdal/__migrations__/0001_create_class_file.py +61 -0
  5. amsdal/__migrations__/0002_create_class_file.py +109 -0
  6. amsdal/__migrations__/0003_update_class_file.py +91 -0
  7. amsdal/__migrations__/0004_update_class_file.py +45 -0
  8. amsdal/cloud/__init__.cpython-312-darwin.so +0 -0
  9. amsdal/cloud/client.cpython-312-darwin.so +0 -0
  10. amsdal/cloud/constants.cpython-312-darwin.so +0 -0
  11. amsdal/cloud/enums.cpython-312-darwin.so +0 -0
  12. amsdal/cloud/models/__init__.cpython-312-darwin.so +0 -0
  13. amsdal/cloud/models/base.cpython-312-darwin.so +0 -0
  14. amsdal/cloud/services/__init__.cpython-312-darwin.so +0 -0
  15. amsdal/cloud/services/actions/__init__.cpython-312-darwin.so +0 -0
  16. amsdal/cloud/services/actions/add_allowlist_ip.cpython-312-darwin.so +0 -0
  17. amsdal/cloud/services/actions/add_basic_auth.cpython-312-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_dependency.cpython-312-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_secret.cpython-312-darwin.so +0 -0
  20. amsdal/cloud/services/actions/base.cpython-312-darwin.so +0 -0
  21. amsdal/cloud/services/actions/create_deploy.cpython-312-darwin.so +0 -0
  22. amsdal/cloud/services/actions/create_env.cpython-312-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_session.cpython-312-darwin.so +0 -0
  24. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-312-darwin.so +0 -0
  25. amsdal/cloud/services/actions/delete_basic_auth.cpython-312-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_dependency.cpython-312-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_env.cpython-312-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_secret.cpython-312-darwin.so +0 -0
  29. amsdal/cloud/services/actions/destroy_deploy.cpython-312-darwin.so +0 -0
  30. amsdal/cloud/services/actions/expose_db.cpython-312-darwin.so +0 -0
  31. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-312-darwin.so +0 -0
  32. amsdal/cloud/services/actions/get_monitoring_info.cpython-312-darwin.so +0 -0
  33. amsdal/cloud/services/actions/list_dependencies.cpython-312-darwin.so +0 -0
  34. amsdal/cloud/services/actions/list_deploys.cpython-312-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_envs.cpython-312-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_secrets.cpython-312-darwin.so +0 -0
  37. amsdal/cloud/services/actions/manager.cpython-312-darwin.so +0 -0
  38. amsdal/cloud/services/actions/signup_action.cpython-312-darwin.so +0 -0
  39. amsdal/cloud/services/actions/update_deploy.cpython-312-darwin.so +0 -0
  40. amsdal/cloud/services/auth/__init__.cpython-312-darwin.so +0 -0
  41. amsdal/cloud/services/auth/base.cpython-312-darwin.so +0 -0
  42. amsdal/cloud/services/auth/credentials.cpython-312-darwin.so +0 -0
  43. amsdal/cloud/services/auth/manager.cpython-312-darwin.so +0 -0
  44. amsdal/cloud/services/auth/signup_service.cpython-312-darwin.so +0 -0
  45. amsdal/cloud/services/auth/token.cpython-312-darwin.so +0 -0
  46. amsdal/configs/main.py +17 -1
  47. amsdal/configs/main.pyi +7 -3
  48. amsdal/contrib/__init__.cpython-312-darwin.so +0 -0
  49. amsdal/contrib/auth/errors.py +36 -0
  50. amsdal/contrib/auth/errors.pyi +12 -0
  51. amsdal/contrib/auth/lifecycle/consumer.py +3 -3
  52. amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
  53. amsdal/contrib/auth/migrations/0000_initial.py +55 -52
  54. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  55. amsdal/contrib/auth/models/__init__.py +1 -0
  56. amsdal/contrib/auth/models/backup_code.py +85 -0
  57. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  58. amsdal/contrib/auth/models/login_session.py +117 -0
  59. amsdal/contrib/auth/models/mfa_device.py +86 -0
  60. amsdal/contrib/auth/models/sms_device.py +113 -0
  61. amsdal/contrib/auth/models/totp_device.py +58 -0
  62. amsdal/contrib/auth/models/user.py +50 -0
  63. amsdal/contrib/auth/services/__init__.py +1 -0
  64. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  65. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  66. amsdal/contrib/auth/services/totp_service.py +358 -0
  67. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  68. amsdal/contrib/auth/settings.py +8 -0
  69. amsdal/contrib/auth/settings.pyi +8 -0
  70. amsdal/contrib/auth/transactions/__init__.py +1 -0
  71. amsdal/contrib/auth/transactions/mfa_device_transactions.py +458 -0
  72. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  73. amsdal/contrib/auth/transactions/totp_transactions.py +203 -0
  74. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  75. amsdal/contrib/auth/utils/mfa.py +257 -0
  76. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  77. amsdal/contrib/frontend_configs/conversion/convert.py +32 -5
  78. amsdal/contrib/frontend_configs/migrations/0000_initial.py +154 -183
  79. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  80. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  81. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  82. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +57 -1
  83. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  84. amsdal/contrib/frontend_configs/models/frontend_control_config.py +69 -46
  85. amsdal/fixtures/__init__.cpython-312-darwin.so +0 -0
  86. amsdal/fixtures/manager.cpython-312-darwin.so +0 -0
  87. amsdal/fixtures/utils.cpython-312-darwin.so +0 -0
  88. amsdal/manager.cpython-312-darwin.so +0 -0
  89. amsdal/manager.pyi +5 -0
  90. amsdal/mixins/__init__.cpython-312-darwin.so +0 -0
  91. amsdal/mixins/class_versions_mixin.cpython-312-darwin.so +0 -0
  92. amsdal/models/core/class_object.py +7 -6
  93. amsdal/models/core/class_property.py +7 -1
  94. amsdal/models/core/file.py +168 -81
  95. amsdal/models/core/storage_metadata.py +15 -0
  96. amsdal/models/mixins.py +31 -0
  97. amsdal/models/types/object.py +3 -3
  98. amsdal/schemas/core/class_object/model.json +20 -0
  99. amsdal/schemas/core/class_property/model.json +19 -0
  100. amsdal/schemas/core/file/properties/validate_data.py +2 -3
  101. amsdal/schemas/core/storage_metadata/model.json +52 -0
  102. amsdal/schemas/interfaces.pyi +1 -1
  103. amsdal/schemas/manager.cpython-312-darwin.so +0 -0
  104. amsdal/schemas/mixins/check_dependencies_mixin.py +23 -8
  105. amsdal/schemas/mixins/check_dependencies_mixin.pyi +5 -2
  106. amsdal/schemas/utils.pyi +2 -2
  107. amsdal/services/__init__.py +11 -0
  108. amsdal/services/__init__.pyi +4 -0
  109. amsdal/services/external_connections.py +262 -0
  110. amsdal/services/external_connections.pyi +190 -0
  111. amsdal/services/external_model_generator.py +350 -0
  112. amsdal/services/external_model_generator.pyi +134 -0
  113. amsdal/services/transaction_execution.cpython-312-darwin.so +0 -0
  114. amsdal/services/transaction_execution.pyi +1 -0
  115. amsdal/storages/__init__.py +20 -0
  116. amsdal/storages/__init__.pyi +8 -0
  117. amsdal/storages/file_system.py +214 -0
  118. amsdal/storages/file_system.pyi +36 -0
  119. amsdal/utils/rollback/__init__.pyi +6 -0
  120. amsdal/utils/tests/enums.py +0 -2
  121. amsdal/utils/tests/helpers.py +213 -381
  122. amsdal/utils/tests/migrations.py +157 -0
  123. {amsdal-0.4.10.dist-info → amsdal-0.5.33.dist-info}/METADATA +17 -11
  124. {amsdal-0.4.10.dist-info → amsdal-0.5.33.dist-info}/RECORD +131 -124
  125. {amsdal-0.4.10.dist-info → amsdal-0.5.33.dist-info}/WHEEL +1 -1
  126. amsdal/__migrations__/0001_datetime_type.py +0 -18
  127. amsdal/__migrations__/0002_fixture_order.py +0 -44
  128. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -44
  129. amsdal/contrib/auth/models/login_session.pyi +0 -37
  130. amsdal/contrib/auth/models/permission.pyi +0 -18
  131. amsdal/contrib/auth/models/user.pyi +0 -46
  132. amsdal/contrib/frontend_configs/models/frontend_activator_config.pyi +0 -12
  133. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.pyi +0 -7
  134. amsdal/contrib/frontend_configs/models/frontend_config_control_action.pyi +0 -32
  135. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.pyi +0 -11
  136. amsdal/contrib/frontend_configs/models/frontend_config_option.pyi +0 -8
  137. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.pyi +0 -8
  138. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.pyi +0 -9
  139. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.pyi +0 -10
  140. amsdal/contrib/frontend_configs/models/frontend_config_validator.pyi +0 -15
  141. amsdal/contrib/frontend_configs/models/frontend_control_config.pyi +0 -35
  142. amsdal/contrib/frontend_configs/models/frontend_model_config.pyi +0 -9
  143. amsdal/models/__init__.pyi +0 -9
  144. amsdal/models/core/class_object.pyi +0 -24
  145. amsdal/models/core/class_object_meta.py +0 -26
  146. amsdal/models/core/class_object_meta.pyi +0 -15
  147. amsdal/models/core/class_property.pyi +0 -11
  148. amsdal/models/core/class_property_meta.py +0 -15
  149. amsdal/models/core/class_property_meta.pyi +0 -10
  150. amsdal/models/core/file.pyi +0 -104
  151. amsdal/models/core/fixture.pyi +0 -14
  152. amsdal/models/core/option.pyi +0 -8
  153. amsdal/models/core/validator.pyi +0 -8
  154. amsdal/models/types/object.pyi +0 -16
  155. amsdal/schemas/core/class_object_meta/model.json +0 -59
  156. amsdal/schemas/core/class_property_meta/model.json +0 -23
  157. amsdal/services/__init__.cpython-312-darwin.so +0 -0
  158. /amsdal/contrib/auth/{models → services}/__init__.pyi +0 -0
  159. /amsdal/contrib/{frontend_configs/models → auth/transactions}/__init__.pyi +0 -0
  160. /amsdal/{models/core/__init__.pyi → contrib/auth/utils/__init__.py} +0 -0
  161. /amsdal/{models/types → contrib/auth/utils}/__init__.pyi +0 -0
  162. {amsdal-0.4.10.dist-info → amsdal-0.5.33.dist-info}/licenses/LICENSE.txt +0 -0
  163. {amsdal-0.4.10.dist-info → amsdal-0.5.33.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,188 @@
1
+ from amsdal_models.migration import migrations
2
+ from amsdal_utils.models.enums import ModuleType
3
+
4
+
5
+ class Migration(migrations.Migration):
6
+ operations: list[migrations.Operation] = [
7
+ # Update LoginSession model to add mfa_code field
8
+ migrations.UpdateClass(
9
+ module_type=ModuleType.CONTRIB,
10
+ class_name="LoginSession",
11
+ old_schema={
12
+ "title": "LoginSession",
13
+ "required": ["email", "password"],
14
+ "properties": {
15
+ "email": {"type": "string", "title": "Email"},
16
+ "password": {"type": "string", "title": "Password (hash)"},
17
+ "token": {"type": "string", "title": "Token"},
18
+ },
19
+ "custom_code": "from datetime import UTC\nfrom datetime import datetime\nfrom datetime import timedelta\nfrom typing import Any\n\nimport jwt\n\n\n@property\ndef display_name(self) -> str:\n \"\"\"\n Returns the display name of the user.\n\n This method returns the email of the user as their display name.\n\n Returns:\n str: The email of the user.\n \"\"\"\n return self.email\n\nasync def apre_create(self) -> None:\n import bcrypt\n\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.models.user import User\n user = await User.objects.filter(email=self.email).latest().first().aexecute()\n if not user:\n msg = 'User not found'\n raise AuthenticationError(msg)\n if not bcrypt.checkpw(self.password.encode(), user.password):\n msg = 'Invalid password'\n raise AuthenticationError(msg)\n self.password = 'validated'\n\nasync def apre_update(self) -> None:\n from amsdal.contrib.auth.errors import AuthenticationError\n msg = 'Update not allowed'\n raise AuthenticationError(msg)\n\ndef pre_create(self) -> None:\n import bcrypt\n\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.models.user import User\n user = User.objects.filter(email=self.email).latest().first().execute()\n if not user:\n msg = 'User not found'\n raise AuthenticationError(msg)\n if not bcrypt.checkpw(self.password.encode(), user.password):\n msg = 'Invalid password'\n raise AuthenticationError(msg)\n self.password = 'validated'\n\ndef pre_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Pre-initializes a user object by validating email and password, and generating a JWT token.\n\n This method checks if the object is new and validates the provided email and password.\n If the email and password are valid, it generates a JWT token and adds it to the kwargs.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing user details.\n\n Raises:\n AuthenticationError: If the email or password is invalid.\n \"\"\"\n if not is_new_object or '_metadata' in kwargs:\n return\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.settings import auth_settings\n email = kwargs.get('email', None)\n password = kwargs.get('password', None)\n if not email:\n msg = \"Email can't be empty\"\n raise AuthenticationError(msg)\n if not password:\n msg = \"Password can't be empty\"\n raise AuthenticationError(msg)\n lowercased_email = email.lower()\n kwargs['email'] = lowercased_email\n if not auth_settings.AUTH_JWT_KEY:\n msg = 'JWT key is not set'\n raise AuthenticationError(msg)\n expiration_time = datetime.now(tz=UTC) + timedelta(seconds=auth_settings.AUTH_TOKEN_EXPIRATION)\n token = jwt.encode({'email': lowercased_email, 'exp': expiration_time}, key=auth_settings.AUTH_JWT_KEY, algorithm='HS256')\n kwargs['token'] = token\n\ndef pre_update(self) -> None:\n from amsdal.contrib.auth.errors import AuthenticationError\n msg = 'Update not allowed'\n raise AuthenticationError(msg)",
20
+ "storage_metadata": {
21
+ "table_name": "LoginSession",
22
+ "db_fields": {},
23
+ "primary_key": ["partition_key"],
24
+ "foreign_keys": {},
25
+ },
26
+ },
27
+ new_schema={
28
+ "title": "LoginSession",
29
+ "required": ["email", "password"],
30
+ "properties": {
31
+ "email": {"type": "string", "title": "Email"},
32
+ "password": {"type": "string", "title": "Password (hash)"},
33
+ "token": {"type": "string", "title": "Token"},
34
+ "mfa_code": {"type": "string", "title": "MFA Code", "default": None},
35
+ },
36
+ "custom_code": "import typing as t\nfrom datetime import UTC\nfrom datetime import datetime\nfrom datetime import timedelta\nfrom typing import Any\n\nimport jwt\n\nif t.TYPE_CHECKING:\n from amsdal.contrib.auth.models.mfa_device import MFADevice\n\n\n@property\ndef display_name(self) -> str:\n \"\"\"\n Returns the display name of the user.\n\n This method returns the email of the user as their display name.\n\n Returns:\n str: The email of the user.\n \"\"\"\n return self.email\n\ndef pre_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Pre-initializes a user object by validating email and password, and generating a JWT token.\n\n This method checks if the object is new and validates the provided email and password.\n If the email and password are valid, it generates a JWT token and adds it to the kwargs.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing user details.\n\n Raises:\n AuthenticationError: If the email or password is invalid.\n \"\"\"\n if not is_new_object or '_metadata' in kwargs:\n return\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.settings import auth_settings\n\n email = kwargs.get('email', None)\n password = kwargs.get('password', None)\n if not email:\n msg = \"Email can't be empty\"\n raise AuthenticationError(msg)\n if not password:\n msg = \"Password can't be empty\"\n raise AuthenticationError(msg)\n lowercased_email = email.lower()\n kwargs['email'] = lowercased_email\n\n if not auth_settings.AUTH_JWT_KEY:\n msg = 'JWT key is not set'\n raise AuthenticationError(msg)\n\n expiration_time = datetime.now(tz=UTC) + timedelta(seconds=auth_settings.AUTH_TOKEN_EXPIRATION)\n token = jwt.encode(\n {'email': lowercased_email, 'exp': expiration_time},\n key=auth_settings.AUTH_JWT_KEY,\n algorithm='HS256',\n )\n kwargs['token'] = token\n\ndef pre_create(self) -> None:\n import bcrypt\n\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.errors import InvalidMFACodeError\n from amsdal.contrib.auth.errors import MFARequiredError\n from amsdal.contrib.auth.models.user import User\n from amsdal.contrib.auth.utils.mfa import get_active_user_devices\n\n user = User.objects.filter(email=self.email).latest().first().execute()\n\n if not user:\n msg = 'User not found'\n raise AuthenticationError(msg)\n\n if not bcrypt.checkpw(self.password.encode(), user.password):\n msg = 'Invalid password'\n raise AuthenticationError(msg)\n\n devices = get_active_user_devices(user)\n if any(devices.values()):\n if not self.mfa_code:\n msg = 'MFA verification is required. Please provide an MFA code.'\n raise MFARequiredError(msg)\n\n # Verify MFA code against user's devices\n if not self._verify_mfa_code(devices, self.mfa_code):\n msg = 'Invalid MFA code'\n raise InvalidMFACodeError(msg)\n\n self.password = 'validated'\n\ndef pre_update(self) -> None:\n from amsdal.contrib.auth.errors import AuthenticationError\n\n msg = 'Update not allowed'\n raise AuthenticationError(msg)\n\nasync def apre_create(self) -> None:\n import bcrypt\n\n from amsdal.contrib.auth.errors import AuthenticationError\n from amsdal.contrib.auth.errors import InvalidMFACodeError\n from amsdal.contrib.auth.errors import MFARequiredError\n from amsdal.contrib.auth.models.user import User\n from amsdal.contrib.auth.utils.mfa import aget_active_user_devices\n\n user = await User.objects.filter(email=self.email).latest().first().aexecute()\n\n if not user:\n msg = 'User not found'\n raise AuthenticationError(msg)\n\n if not bcrypt.checkpw(self.password.encode(), user.password):\n msg = 'Invalid password'\n raise AuthenticationError(msg)\n\n devices = await aget_active_user_devices(user)\n # Check if MFA is required for this user\n if any(devices.values()):\n if not self.mfa_code:\n msg = 'MFA verification is required. Please provide an MFA code.'\n raise MFARequiredError(msg)\n\n # Verify MFA code against user's devices\n if not await self._averify_mfa_code(devices, self.mfa_code):\n msg = 'Invalid MFA code'\n raise InvalidMFACodeError(msg)\n\n self.password = 'validated'\n\nasync def apre_update(self) -> None:\n from amsdal.contrib.auth.errors import AuthenticationError\n\n msg = 'Update not allowed'\n raise AuthenticationError(msg)\n\ndef _verify_mfa_code(self, devices, code: str) -> bool:\n from datetime import UTC\n from datetime import datetime\n from amsdal.contrib.auth.utils.mfa import DeviceType\n\n for device_type, specific_devices in devices.items():\n try:\n for device in specific_devices:\n if device.verify_code(code):\n # Update last_used_at\n device.last_used_at = datetime.now(tz=UTC)\n\n # Special handling for backup codes (mark as used)\n if device_type == DeviceType.BACKUP_CODE:\n device.mark_as_used()\n # Special handling for email devices (clear code)\n elif device_type == DeviceType.EMAIL:\n device.clear_code()\n\n device.save()\n return True\n\n except Exception:\n # Continue to next device type if verification fails\n continue\n\n return False\n\nasync def _averify_mfa_code(self, devices, code: str) -> bool:\n from datetime import UTC\n from datetime import datetime\n from amsdal.contrib.auth.utils.mfa import DeviceType\n\n for device_type, specific_devices in devices.items():\n try:\n for device in specific_devices:\n if device.verify_code(code):\n # Update last_used_at\n device.last_used_at = datetime.now(tz=UTC)\n\n # Special handling for backup codes (mark as used)\n if device_type == DeviceType.BACKUP_CODE:\n device.mark_as_used()\n # Special handling for email devices (clear code)\n elif device_type == DeviceType.EMAIL:\n device.clear_code()\n\n await device.asave()\n return True\n\n except Exception:\n # Continue to next device type if verification fails\n continue\n\n return False",
37
+ "storage_metadata": {
38
+ "table_name": "LoginSession",
39
+ "db_fields": {},
40
+ "primary_key": ["partition_key"],
41
+ "foreign_keys": {},
42
+ },
43
+ },
44
+ ),
45
+ # Create MFADevice base model
46
+ migrations.CreateClass(
47
+ module_type=ModuleType.CONTRIB,
48
+ class_name="MFADevice",
49
+ new_schema={
50
+ "title": "MFADevice",
51
+ "type": "object",
52
+ "required": ["user_email", "name"],
53
+ "properties": {
54
+ "user_email": {"type": "string", "title": "User Email"},
55
+ "device_type": {"type": "string", "title": "Device Type", "default": None},
56
+ "name": {"type": "string", "title": "Device Name"},
57
+ "is_active": {"type": "boolean", "title": "Is Active", "default": True},
58
+ "confirmed": {"type": "boolean", "title": "Confirmed", "default": False},
59
+ "created_at": {"type": "datetime", "title": "Created At"},
60
+ "last_used_at": {"type": "datetime", "title": "Last Used At", "default": None},
61
+ },
62
+ "custom_code": "@property\ndef display_name(self) -> str:\n \"\"\"\n Returns the display name of the device.\n\n Returns:\n str: The device name and type.\n \"\"\"\n return f'{self.name} ({self.device_type})'\n\ndef __repr__(self) -> str:\n return str(self)\n\ndef __str__(self) -> str:\n return f'MFADevice(name={self.name}, type={self.device_type}, user={self.user_email})'\n\ndef has_object_permission(self, user, action: str) -> bool:\n \"\"\"\n Check if a user has permission to perform an action on this device.\n\n Users can only manage their own devices. Admins with wildcard permissions\n can manage all devices.\n\n Args:\n user: The user requesting the action.\n action: The action being requested (read, update, delete, etc.).\n\n Returns:\n bool: True if the user has permission, False otherwise.\n \"\"\"\n # Users can only manage their own devices\n if self.user_email == user.email:\n return True\n\n # Check if user has admin permissions (wildcard model permissions)\n if user.permissions:\n for permission in user.permissions:\n if permission.model == '*' and permission.action in ('*', action):\n return True\n if permission.model == 'MFADevice' and permission.action in ('*', action):\n return True\n\n return False",
63
+ "storage_metadata": {
64
+ "table_name": "MFADevice",
65
+ "db_fields": {},
66
+ "primary_key": ["partition_key"],
67
+ "foreign_keys": {},
68
+ },
69
+ },
70
+ ),
71
+ # Create TOTPDevice model
72
+ migrations.CreateClass(
73
+ module_type=ModuleType.CONTRIB,
74
+ class_name="TOTPDevice",
75
+ new_schema={
76
+ "title": "TOTPDevice",
77
+ "type": "MFADevice",
78
+ "required": ["user_email", "name", "secret"],
79
+ "properties": {
80
+ "user_email": {"type": "string", "title": "User Email"},
81
+ "device_type": {"type": "string", "title": "Device Type", "default": None},
82
+ "name": {"type": "string", "title": "Device Name"},
83
+ "is_active": {"type": "boolean", "title": "Is Active", "default": True},
84
+ "confirmed": {"type": "boolean", "title": "Confirmed", "default": False},
85
+ "created_at": {"type": "datetime", "title": "Created At"},
86
+ "last_used_at": {"type": "datetime", "title": "Last Used At", "default": None},
87
+ "secret": {"type": "string", "title": "TOTP Secret"},
88
+ "qr_code_url": {"type": "string", "title": "QR Code URL", "default": None},
89
+ "digits": {"type": "integer", "title": "Code Digits", "default": 6},
90
+ "step": {"type": "integer", "title": "Time Step (seconds)", "default": 30},
91
+ },
92
+ "custom_code": "from typing import Any\nfrom amsdal.contrib.auth.utils.mfa import DeviceType\n\n\ndef post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Post-initializes a TOTP device by setting the device type.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing device details.\n \"\"\"\n super().post_init(is_new_object=is_new_object, kwargs=kwargs)\n self.device_type = DeviceType.TOTP\n\ndef verify_code(self, code: str) -> bool:\n \"\"\"\n Verify a TOTP code against this device's secret.\n\n Args:\n code (str): The TOTP code to verify.\n\n Returns:\n bool: True if the code is valid, False otherwise.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import verify_totp_code\n\n return verify_totp_code(self.secret, code, self.digits, self.step)\n\ndef __str__(self) -> str:\n return f'TOTPDevice(name={self.name}, user={self.user_email})'",
93
+ "storage_metadata": {
94
+ "table_name": "TOTPDevice",
95
+ "db_fields": {},
96
+ "primary_key": ["partition_key"],
97
+ "foreign_keys": {},
98
+ },
99
+ },
100
+ ),
101
+ # Create BackupCode model
102
+ migrations.CreateClass(
103
+ module_type=ModuleType.CONTRIB,
104
+ class_name="BackupCode",
105
+ new_schema={
106
+ "title": "BackupCode",
107
+ "type": "MFADevice",
108
+ "required": ["user_email", "name", "code"],
109
+ "properties": {
110
+ "user_email": {"type": "string", "title": "User Email"},
111
+ "device_type": {"type": "string", "title": "Device Type", "default": None},
112
+ "name": {"type": "string", "title": "Device Name"},
113
+ "is_active": {"type": "boolean", "title": "Is Active", "default": True},
114
+ "confirmed": {"type": "boolean", "title": "Confirmed", "default": False},
115
+ "created_at": {"type": "datetime", "title": "Created At"},
116
+ "last_used_at": {"type": "datetime", "title": "Last Used At", "default": None},
117
+ "code": {"type": "binary", "title": "Hashed Code"},
118
+ "used": {"type": "boolean", "title": "Used", "default": False},
119
+ "used_at": {"type": "datetime", "title": "Used At", "default": None},
120
+ },
121
+ "custom_code": "from typing import Any\nfrom datetime import UTC\nfrom datetime import datetime\nfrom amsdal.contrib.auth.utils.mfa import DeviceType\n\n\ndef post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Post-initializes a backup code by setting the device type and hashing the code.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing device details.\n \"\"\"\n super().post_init(is_new_object=is_new_object, kwargs=kwargs)\n self.device_type = DeviceType.BACKUP_CODE\n\n # Hash the code if it's provided as a string (new object)\n code_value = kwargs.get('code', None)\n if is_new_object and isinstance(code_value, str):\n from amsdal.contrib.auth.utils.mfa import hash_backup_code\n\n self.code = hash_backup_code(code_value)\n\n # Backup codes are confirmed by default (no verification needed)\n if is_new_object:\n self.confirmed = True\n\ndef verify_code(self, code: str) -> bool:\n \"\"\"\n Verify a backup code against this device's stored hash.\n\n Args:\n code (str): The code to verify.\n\n Returns:\n bool: True if the code is valid and not yet used, False otherwise.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import verify_backup_code\n\n # Code must not have been used already\n if self.used:\n return False\n\n return verify_backup_code(self.code, code)\n\ndef mark_as_used(self) -> None:\n \"\"\"\n Mark this backup code as used.\n\n This should be called after successful authentication with this code.\n \"\"\"\n self.used = True\n self.used_at = datetime.now(tz=UTC)\n self.last_used_at = datetime.now(tz=UTC)\n\ndef __str__(self) -> str:\n status = 'used' if self.used else 'available'\n return f'BackupCode(name={self.name}, user={self.user_email}, status={status})'",
122
+ "storage_metadata": {
123
+ "table_name": "BackupCode",
124
+ "db_fields": {},
125
+ "primary_key": ["partition_key"],
126
+ "foreign_keys": {},
127
+ },
128
+ },
129
+ ),
130
+ # Create EmailMFADevice model
131
+ migrations.CreateClass(
132
+ module_type=ModuleType.CONTRIB,
133
+ class_name="EmailMFADevice",
134
+ new_schema={
135
+ "title": "EmailMFADevice",
136
+ "type": "MFADevice",
137
+ "required": ["user_email", "name", "email"],
138
+ "properties": {
139
+ "user_email": {"type": "string", "title": "User Email"},
140
+ "device_type": {"type": "string", "title": "Device Type", "default": None},
141
+ "name": {"type": "string", "title": "Device Name"},
142
+ "is_active": {"type": "boolean", "title": "Is Active", "default": True},
143
+ "confirmed": {"type": "boolean", "title": "Confirmed", "default": False},
144
+ "created_at": {"type": "datetime", "title": "Created At"},
145
+ "last_used_at": {"type": "datetime", "title": "Last Used At", "default": None},
146
+ "email": {"type": "string", "title": "Email Address"},
147
+ "code": {"type": "string", "title": "Current Code", "default": None},
148
+ "code_expires_at": {"type": "datetime", "title": "Code Expiration", "default": None},
149
+ },
150
+ "custom_code": "from typing import Any\nfrom amsdal.contrib.auth.utils.mfa import DeviceType\n\n\ndef post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Post-initializes an email MFA device by setting the device type.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing device details.\n \"\"\"\n super().post_init(is_new_object=is_new_object, kwargs=kwargs)\n self.device_type = DeviceType.EMAIL\n\n # Email devices are confirmed by default (verification happens through email ownership)\n if is_new_object:\n self.confirmed = True\n\ndef generate_and_send_code(self) -> str:\n \"\"\"\n Generate a new MFA code and send it via email.\n\n This method generates a random numeric code, sets its expiration time,\n and sends it to the configured email address.\n\n Returns:\n str: The generated code (for testing purposes).\n\n Note:\n In production, you should implement actual email sending logic here\n or call an email service.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import generate_email_mfa_code\n from amsdal.contrib.auth.utils.mfa import get_email_code_expiration\n\n # Generate new code\n self.code = generate_email_mfa_code()\n self.code_expires_at = get_email_code_expiration()\n\n return self.code\n\ndef verify_code(self, code: str) -> bool:\n \"\"\"\n Verify an email MFA code.\n\n Args:\n code (str): The code to verify.\n\n Returns:\n bool: True if the code is valid and not expired, False otherwise.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import is_email_code_valid\n\n # Check if code matches\n if not self.code or self.code != code:\n return False\n\n # Check if code is expired\n if not self.code_expires_at or not is_email_code_valid(self.code_expires_at):\n return False\n\n return True\n\ndef clear_code(self) -> None:\n \"\"\"\n Clear the current code after successful use or expiration.\n \"\"\"\n self.code = None\n self.code_expires_at = None\n\ndef __str__(self) -> str:\n return f'EmailMFADevice(name={self.name}, email={self.email}, user={self.user_email})'",
151
+ "storage_metadata": {
152
+ "table_name": "EmailMFADevice",
153
+ "db_fields": {},
154
+ "primary_key": ["partition_key"],
155
+ "foreign_keys": {},
156
+ },
157
+ },
158
+ ),
159
+ # Create SMSDevice model
160
+ migrations.CreateClass(
161
+ module_type=ModuleType.CONTRIB,
162
+ class_name="SMSDevice",
163
+ new_schema={
164
+ "title": "SMSDevice",
165
+ "type": "MFADevice",
166
+ "required": ["user_email", "name", "phone_number"],
167
+ "properties": {
168
+ "user_email": {"type": "string", "title": "User Email"},
169
+ "device_type": {"type": "string", "title": "Device Type", "default": None},
170
+ "name": {"type": "string", "title": "Device Name"},
171
+ "is_active": {"type": "boolean", "title": "Is Active", "default": True},
172
+ "confirmed": {"type": "boolean", "title": "Confirmed", "default": False},
173
+ "created_at": {"type": "datetime", "title": "Created At"},
174
+ "last_used_at": {"type": "datetime", "title": "Last Used At", "default": None},
175
+ "phone_number": {"type": "string", "title": "Phone Number"},
176
+ "code": {"type": "string", "title": "Current Code", "default": None},
177
+ "code_expires_at": {"type": "datetime", "title": "Code Expiration", "default": None},
178
+ },
179
+ "custom_code": "from typing import Any\nfrom amsdal.contrib.auth.utils.mfa import DeviceType\n\n\ndef post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:\n \"\"\"\n Post-initializes an SMS MFA device by setting the device type.\n\n Args:\n is_new_object (bool): Indicates if the object is new.\n kwargs (dict[str, Any]): The keyword arguments containing device details.\n \"\"\"\n super().post_init(is_new_object=is_new_object, kwargs=kwargs)\n self.device_type = DeviceType.SMS\n\ndef generate_and_send_code(self) -> str:\n \"\"\"\n Generate a new MFA code and send it via SMS.\n\n This method generates a random numeric code, sets its expiration time,\n and sends it to the configured phone number.\n\n Returns:\n str: The generated code (for testing purposes).\n\n Raises:\n NotImplementedError: This feature requires SMS service integration.\n\n Note:\n Full implementation requires integration with an SMS provider.\n Example providers: Twilio, AWS SNS, MessageBird, etc.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import generate_email_mfa_code\n from amsdal.contrib.auth.utils.mfa import get_email_code_expiration\n\n # Generate new code\n self.code = generate_email_mfa_code()\n self.code_expires_at = get_email_code_expiration()\n\n msg = 'SMS MFA is not yet implemented. Please integrate with an SMS service provider (e.g., Twilio, AWS SNS).'\n raise NotImplementedError(msg)\n\ndef verify_code(self, code: str) -> bool:\n \"\"\"\n Verify an SMS MFA code.\n\n Args:\n code (str): The code to verify.\n\n Returns:\n bool: True if the code is valid and not expired, False otherwise.\n \"\"\"\n from amsdal.contrib.auth.utils.mfa import is_email_code_valid\n\n # Check if code matches\n if not self.code or self.code != code:\n return False\n\n # Check if code is expired\n if not self.code_expires_at or not is_email_code_valid(self.code_expires_at):\n return False\n\n return True\n\ndef clear_code(self) -> None:\n \"\"\"\n Clear the current code after successful use or expiration.\n \"\"\"\n self.code = None\n self.code_expires_at = None\n\ndef __str__(self) -> str:\n return f'SMSDevice(name={self.name}, phone={self.phone_number}, user={self.user_email})'",
180
+ "storage_metadata": {
181
+ "table_name": "SMSDevice",
182
+ "db_fields": {},
183
+ "primary_key": ["partition_key"],
184
+ "foreign_keys": {},
185
+ },
186
+ },
187
+ ),
188
+ ]
@@ -0,0 +1 @@
1
+ # Auth models package
@@ -0,0 +1,85 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from typing import ClassVar
4
+
5
+ from amsdal_utils.models.enums import ModuleType
6
+ from pydantic.fields import Field
7
+
8
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
9
+ from amsdal.contrib.auth.utils.mfa import DeviceType
10
+
11
+
12
+ class BackupCode(MFADevice):
13
+ """
14
+ Backup/Recovery code model for MFA.
15
+
16
+ This model represents a one-time use backup code that can be used for authentication
17
+ when the primary MFA device is unavailable. Each code can only be used once.
18
+
19
+ Attributes:
20
+ code (bytes): The hashed backup code.
21
+ used (bool): Whether the code has been used.
22
+ used_at (datetime | None): When the code was used.
23
+ """
24
+
25
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
26
+
27
+ code: bytes = Field(title='Hashed Code')
28
+ used: bool = Field(False, title='Used')
29
+ used_at: datetime | None = Field(None, title='Used At')
30
+
31
+ def post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
32
+ """
33
+ Post-initializes a backup code by setting the device type and hashing the code.
34
+
35
+ Args:
36
+ is_new_object (bool): Indicates if the object is new.
37
+ kwargs (dict[str, Any]): The keyword arguments containing device details.
38
+ """
39
+ super().post_init(is_new_object=is_new_object, kwargs=kwargs)
40
+ self.device_type = DeviceType.BACKUP_CODE
41
+
42
+ # Hash the code if it's provided as a string (new object)
43
+ code_value = kwargs.get('code', None)
44
+ if is_new_object and isinstance(code_value, str):
45
+ from amsdal.contrib.auth.utils.mfa import hash_backup_code
46
+
47
+ self.code = hash_backup_code(code_value)
48
+
49
+ # Backup codes are confirmed by default (no verification needed)
50
+ if is_new_object:
51
+ self.confirmed = True
52
+
53
+ def verify_code(self, code: str) -> bool:
54
+ """
55
+ Verify a backup code against this device's stored hash.
56
+
57
+ Args:
58
+ code (str): The code to verify.
59
+
60
+ Returns:
61
+ bool: True if the code is valid and not yet used, False otherwise.
62
+ """
63
+ from amsdal.contrib.auth.utils.mfa import verify_backup_code
64
+
65
+ # Code must not have been used already
66
+ if self.used:
67
+ return False
68
+
69
+ return verify_backup_code(self.code, code)
70
+
71
+ def mark_as_used(self) -> None:
72
+ """
73
+ Mark this backup code as used.
74
+
75
+ This should be called after successful authentication with this code.
76
+ """
77
+ from datetime import UTC
78
+
79
+ self.used = True
80
+ self.used_at = datetime.now(tz=UTC)
81
+ self.last_used_at = datetime.now(tz=UTC)
82
+
83
+ def __str__(self) -> str:
84
+ status = 'used' if self.used else 'available'
85
+ return f'BackupCode(name={self.name}, user={self.user_email}, status={status})'
@@ -0,0 +1,108 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from typing import ClassVar
4
+
5
+ from amsdal_utils.models.enums import ModuleType
6
+ from pydantic.fields import Field
7
+
8
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
9
+ from amsdal.contrib.auth.utils.mfa import DeviceType
10
+
11
+
12
+ class EmailMFADevice(MFADevice):
13
+ """
14
+ Email-based MFA device model.
15
+
16
+ This model represents an email-based MFA method where a temporary code is sent
17
+ to the user's email address for authentication.
18
+
19
+ Attributes:
20
+ email (str): The email address to send codes to (could be same as user email or alternate).
21
+ code (str | None): Temporary MFA code (stored temporarily, expires after use).
22
+ code_expires_at (datetime | None): When the current code expires.
23
+ """
24
+
25
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
26
+
27
+ email: str = Field(title='Email Address')
28
+ code: str | None = Field(None, title='Current Code')
29
+ code_expires_at: datetime | None = Field(None, title='Code Expiration')
30
+
31
+ def post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
32
+ """
33
+ Post-initializes an email MFA device by setting the device type.
34
+
35
+ Args:
36
+ is_new_object (bool): Indicates if the object is new.
37
+ kwargs (dict[str, Any]): The keyword arguments containing device details.
38
+ """
39
+ super().post_init(is_new_object=is_new_object, kwargs=kwargs)
40
+ self.device_type = DeviceType.EMAIL
41
+
42
+ # Email devices are confirmed by default (verification happens through email ownership)
43
+ if is_new_object:
44
+ self.confirmed = True
45
+
46
+ def generate_and_send_code(self) -> str:
47
+ """
48
+ Generate a new MFA code and send it via email.
49
+
50
+ This method generates a random numeric code, sets its expiration time,
51
+ and sends it to the configured email address.
52
+
53
+ Returns:
54
+ str: The generated code (for testing purposes).
55
+
56
+ Note:
57
+ In production, you should implement actual email sending logic here
58
+ or call an email service.
59
+ """
60
+ from amsdal.contrib.auth.utils.mfa import generate_email_mfa_code
61
+ from amsdal.contrib.auth.utils.mfa import get_email_code_expiration
62
+
63
+ # Generate new code
64
+ self.code = generate_email_mfa_code()
65
+ self.code_expires_at = get_email_code_expiration()
66
+
67
+ # TODO: Implement actual email sending
68
+ # For now, we'll just store the code
69
+ # In production, integrate with your email service:
70
+ # send_email(
71
+ # to=self.email,
72
+ # subject='Your MFA Code',
73
+ # body=f'Your verification code is: {self.code}'
74
+ # )
75
+
76
+ return self.code
77
+
78
+ def verify_code(self, code: str) -> bool:
79
+ """
80
+ Verify an email MFA code.
81
+
82
+ Args:
83
+ code (str): The code to verify.
84
+
85
+ Returns:
86
+ bool: True if the code is valid and not expired, False otherwise.
87
+ """
88
+ from amsdal.contrib.auth.utils.mfa import is_email_code_valid
89
+
90
+ # Check if code matches
91
+ if not self.code or self.code != code:
92
+ return False
93
+
94
+ # Check if code is expired
95
+ if not self.code_expires_at or not is_email_code_valid(self.code_expires_at):
96
+ return False
97
+
98
+ return True
99
+
100
+ def clear_code(self) -> None:
101
+ """
102
+ Clear the current code after successful use or expiration.
103
+ """
104
+ self.code = None
105
+ self.code_expires_at = None
106
+
107
+ def __str__(self) -> str:
108
+ return f'EmailMFADevice(name={self.name}, email={self.email}, user={self.user_email})'
@@ -1,3 +1,4 @@
1
+ import typing as t
1
2
  from datetime import UTC
2
3
  from datetime import datetime
3
4
  from datetime import timedelta
@@ -9,12 +10,20 @@ from amsdal_models.classes.model import Model
9
10
  from amsdal_utils.models.enums import ModuleType
10
11
  from pydantic.fields import Field
11
12
 
13
+ from amsdal.contrib.auth.utils.mfa import DeviceType
14
+ from amsdal.contrib.auth.utils.mfa import aget_active_user_devices
15
+ from amsdal.contrib.auth.utils.mfa import get_active_user_devices
16
+
17
+ if t.TYPE_CHECKING:
18
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
19
+
12
20
 
13
21
  class LoginSession(Model):
14
22
  __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
15
23
  email: str = Field(title='Email')
16
24
  password: str = Field(title='Password (hash)')
17
25
  token: str | None = Field(None, title='Token')
26
+ mfa_code: str | None = Field(None, title='MFA Code')
18
27
 
19
28
  @property
20
29
  def display_name(self) -> str:
@@ -56,6 +65,7 @@ class LoginSession(Model):
56
65
  msg = "Password can't be empty"
57
66
  raise AuthenticationError(msg)
58
67
  lowercased_email = email.lower()
68
+ kwargs['email'] = lowercased_email
59
69
 
60
70
  if not auth_settings.AUTH_JWT_KEY:
61
71
  msg = 'JWT key is not set'
@@ -73,6 +83,8 @@ class LoginSession(Model):
73
83
  import bcrypt
74
84
 
75
85
  from amsdal.contrib.auth.errors import AuthenticationError
86
+ from amsdal.contrib.auth.errors import InvalidMFACodeError
87
+ from amsdal.contrib.auth.errors import MFARequiredError
76
88
  from amsdal.contrib.auth.models.user import User
77
89
 
78
90
  user = User.objects.filter(email=self.email).latest().first().execute()
@@ -85,6 +97,17 @@ class LoginSession(Model):
85
97
  msg = 'Invalid password'
86
98
  raise AuthenticationError(msg)
87
99
 
100
+ devices = get_active_user_devices(user)
101
+ if any(devices.values()):
102
+ if not self.mfa_code:
103
+ msg = 'MFA verification is required. Please provide an MFA code.'
104
+ raise MFARequiredError(msg)
105
+
106
+ # Verify MFA code against user's devices
107
+ if not self._verify_mfa_code(devices, self.mfa_code):
108
+ msg = 'Invalid MFA code'
109
+ raise InvalidMFACodeError(msg)
110
+
88
111
  self.password = 'validated'
89
112
 
90
113
  def pre_update(self) -> None:
@@ -97,6 +120,8 @@ class LoginSession(Model):
97
120
  import bcrypt
98
121
 
99
122
  from amsdal.contrib.auth.errors import AuthenticationError
123
+ from amsdal.contrib.auth.errors import InvalidMFACodeError
124
+ from amsdal.contrib.auth.errors import MFARequiredError
100
125
  from amsdal.contrib.auth.models.user import User
101
126
 
102
127
  user = await User.objects.filter(email=self.email).latest().first().aexecute()
@@ -109,6 +134,18 @@ class LoginSession(Model):
109
134
  msg = 'Invalid password'
110
135
  raise AuthenticationError(msg)
111
136
 
137
+ devices = await aget_active_user_devices(user)
138
+ # Check if MFA is required for this user
139
+ if any(devices.values()):
140
+ if not self.mfa_code:
141
+ msg = 'MFA verification is required. Please provide an MFA code.'
142
+ raise MFARequiredError(msg)
143
+
144
+ # Verify MFA code against user's devices
145
+ if not await self._averify_mfa_code(devices, self.mfa_code):
146
+ msg = 'Invalid MFA code'
147
+ raise InvalidMFACodeError(msg)
148
+
112
149
  self.password = 'validated'
113
150
 
114
151
  async def apre_update(self) -> None:
@@ -116,3 +153,83 @@ class LoginSession(Model):
116
153
 
117
154
  msg = 'Update not allowed'
118
155
  raise AuthenticationError(msg)
156
+
157
+ def _verify_mfa_code(self, devices: dict[DeviceType, list['MFADevice']], code: str) -> bool: # type: ignore # noqa: F821
158
+ """
159
+ Verify an MFA code against the user's active devices.
160
+
161
+ This method checks all active and confirmed MFA devices for the user
162
+ and attempts to verify the provided code against each one.
163
+
164
+ Args:
165
+ user: The user attempting to authenticate.
166
+ code: The MFA code to verify.
167
+
168
+ Returns:
169
+ bool: True if the code is valid for any device, False otherwise.
170
+ """
171
+ from datetime import UTC
172
+ from datetime import datetime
173
+
174
+ for device_type, specific_devices in devices.items():
175
+ try:
176
+ for device in specific_devices:
177
+ if device.verify_code(code): # type: ignore[attr-defined]
178
+ # Update last_used_at
179
+ device.last_used_at = datetime.now(tz=UTC) # type: ignore[attr-defined]
180
+
181
+ # Special handling for backup codes (mark as used)
182
+ if device_type == DeviceType.BACKUP_CODE:
183
+ device.mark_as_used() # type: ignore[attr-defined]
184
+ # Special handling for email devices (clear code)
185
+ elif device_type == DeviceType.EMAIL:
186
+ device.clear_code() # type: ignore[attr-defined]
187
+
188
+ device.save() # type: ignore[attr-defined]
189
+ return True
190
+
191
+ except Exception: # noqa: S112
192
+ # Continue to next device type if verification fails
193
+ continue
194
+
195
+ return False
196
+
197
+ async def _averify_mfa_code(self, devices: dict[DeviceType, list['MFADevice']], code: str) -> bool: # type: ignore # noqa: F821
198
+ """
199
+ Verify an MFA code against the user's active devices (async version).
200
+
201
+ This method checks all active and confirmed MFA devices for the user
202
+ and attempts to verify the provided code against each one.
203
+
204
+ Args:
205
+ user: The user attempting to authenticate.
206
+ code: The MFA code to verify.
207
+
208
+ Returns:
209
+ bool: True if the code is valid for any device, False otherwise.
210
+ """
211
+ from datetime import UTC
212
+ from datetime import datetime
213
+
214
+ for device_type, specific_devices in devices.items():
215
+ try:
216
+ for device in specific_devices:
217
+ if device.verify_code(code): # type: ignore[attr-defined]
218
+ # Update last_used_at
219
+ device.last_used_at = datetime.now(tz=UTC) # type: ignore[attr-defined]
220
+
221
+ # Special handling for backup codes (mark as used)
222
+ if device_type == DeviceType.BACKUP_CODE:
223
+ device.mark_as_used() # type: ignore[attr-defined]
224
+ # Special handling for email devices (clear code)
225
+ elif device_type == DeviceType.EMAIL:
226
+ device.clear_code() # type: ignore[attr-defined]
227
+
228
+ await device.asave() # type: ignore[attr-defined]
229
+ return True
230
+
231
+ except Exception: # noqa: S112
232
+ # Continue to next device type if verification fails
233
+ continue
234
+
235
+ return False