amsdal 0.3.3__cp311-cp311-macosx_10_9_universal2.whl → 0.5.29__cp311-cp311-macosx_10_9_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 (244) hide show
  1. amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +56 -2
  2. amsdal/__about__.py +1 -1
  3. amsdal/__init__.py +20 -0
  4. amsdal/__init__.pyi +9 -0
  5. amsdal/__migrations__/0000_initial.py +23 -190
  6. amsdal/__migrations__/0001_create_class_file.py +61 -0
  7. amsdal/__migrations__/0002_create_class_file.py +109 -0
  8. amsdal/__migrations__/0003_update_class_file.py +91 -0
  9. amsdal/__migrations__/0004_update_class_file.py +45 -0
  10. amsdal/cloud/__init__.cpython-311-darwin.so +0 -0
  11. amsdal/cloud/client.cpython-311-darwin.so +0 -0
  12. amsdal/cloud/constants.cpython-311-darwin.so +0 -0
  13. amsdal/cloud/enums.cpython-311-darwin.so +0 -0
  14. amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
  15. amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
  16. amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
  17. amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
  20. amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
  21. amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
  22. amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
  24. amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
  25. amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
  29. amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
  30. amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
  31. amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
  32. amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
  33. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
  34. amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
  37. amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
  38. amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
  39. amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
  40. amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
  41. amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
  42. amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
  43. amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
  44. amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
  45. amsdal/cloud/services/auth/credentials.pyi +0 -1
  46. amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
  47. amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
  48. amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
  49. amsdal/cloud/services/auth/token.pyi +0 -1
  50. amsdal/configs/main.py +40 -20
  51. amsdal/configs/main.pyi +19 -18
  52. amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
  53. amsdal/contrib/auth/errors.py +36 -0
  54. amsdal/contrib/auth/errors.pyi +12 -0
  55. amsdal/contrib/auth/fixtures/basic_permissions.json +64 -0
  56. amsdal/contrib/auth/lifecycle/consumer.py +13 -13
  57. amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
  58. amsdal/contrib/auth/migrations/0000_initial.py +69 -31
  59. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  60. amsdal/contrib/auth/models/__init__.py +1 -0
  61. amsdal/contrib/auth/models/backup_code.py +85 -0
  62. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  63. amsdal/contrib/auth/models/login_session.py +235 -0
  64. amsdal/contrib/auth/models/mfa_device.py +86 -0
  65. amsdal/contrib/auth/models/permission.py +23 -0
  66. amsdal/contrib/auth/models/sms_device.py +113 -0
  67. amsdal/contrib/auth/models/totp_device.py +58 -0
  68. amsdal/contrib/auth/models/user.py +156 -0
  69. amsdal/contrib/auth/services/__init__.py +1 -0
  70. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  71. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  72. amsdal/contrib/auth/services/totp_service.py +358 -0
  73. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  74. amsdal/contrib/auth/settings.py +8 -0
  75. amsdal/contrib/auth/settings.pyi +8 -0
  76. amsdal/contrib/auth/transactions/__init__.py +1 -0
  77. amsdal/contrib/auth/transactions/mfa_device_transactions.py +463 -0
  78. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  79. amsdal/contrib/auth/transactions/totp_transactions.py +206 -0
  80. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  81. amsdal/contrib/auth/utils/__init__.py +0 -0
  82. amsdal/contrib/auth/utils/__init__.pyi +0 -0
  83. amsdal/contrib/auth/utils/mfa.py +257 -0
  84. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  85. amsdal/contrib/frontend_configs/conversion/convert.py +85 -25
  86. amsdal/contrib/frontend_configs/conversion/convert.pyi +0 -1
  87. amsdal/contrib/frontend_configs/lifecycle/consumer.py +32 -13
  88. amsdal/contrib/frontend_configs/lifecycle/consumer.pyi +1 -1
  89. amsdal/contrib/frontend_configs/migrations/0000_initial.py +167 -195
  90. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  91. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  92. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  93. amsdal/contrib/frontend_configs/models/__init__.py +0 -0
  94. amsdal/contrib/frontend_configs/models/frontend_activator_config.py +22 -0
  95. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.py +11 -0
  96. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +110 -0
  97. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  98. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.py +21 -0
  99. amsdal/contrib/frontend_configs/models/frontend_config_option.py +12 -0
  100. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.py +17 -0
  101. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.py +13 -0
  102. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.py +14 -0
  103. amsdal/contrib/frontend_configs/models/frontend_config_validator.py +28 -0
  104. amsdal/contrib/frontend_configs/models/frontend_control_config.py +110 -0
  105. amsdal/contrib/frontend_configs/models/frontend_model_config.py +14 -0
  106. amsdal/errors.py +0 -3
  107. amsdal/errors.pyi +0 -1
  108. amsdal/fixtures/__init__.cpython-311-darwin.so +0 -0
  109. amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
  110. amsdal/fixtures/manager.pyi +73 -123
  111. amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
  112. amsdal/fixtures/utils.pyi +9 -0
  113. amsdal/manager.cpython-311-darwin.so +0 -0
  114. amsdal/manager.pyi +9 -96
  115. amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
  116. amsdal/mixins/class_versions_mixin.cpython-311-darwin.so +0 -0
  117. amsdal/models/__init__.py +19 -0
  118. amsdal/models/core/__init__.py +0 -0
  119. amsdal/models/core/class_object.py +38 -0
  120. amsdal/models/core/class_property.py +26 -0
  121. amsdal/models/core/file.py +243 -0
  122. amsdal/models/core/fixture.py +25 -0
  123. amsdal/models/core/option.py +11 -0
  124. amsdal/models/core/storage_metadata.py +15 -0
  125. amsdal/models/core/validator.py +12 -0
  126. amsdal/models/mixins.py +31 -0
  127. amsdal/models/types/__init__.py +0 -0
  128. amsdal/models/types/object.py +26 -0
  129. amsdal/queryset/__init__.py +21 -0
  130. amsdal/queryset/__init__.pyi +6 -0
  131. amsdal/schemas/core/class_object/model.json +20 -0
  132. amsdal/schemas/core/class_property/model.json +19 -0
  133. amsdal/schemas/core/file/properties/from_file.py +1 -1
  134. amsdal/schemas/core/file/properties/validate_data.py +3 -4
  135. amsdal/schemas/core/storage_metadata/model.json +52 -0
  136. amsdal/schemas/interfaces.py +25 -0
  137. amsdal/schemas/interfaces.pyi +20 -0
  138. amsdal/schemas/manager.cpython-311-darwin.so +0 -0
  139. amsdal/schemas/manager.py +0 -116
  140. amsdal/schemas/manager.pyi +0 -65
  141. amsdal/schemas/mixins/__init__.py +0 -0
  142. amsdal/schemas/mixins/__init__.pyi +0 -0
  143. amsdal/schemas/mixins/check_dependencies_mixin.py +130 -0
  144. amsdal/schemas/mixins/check_dependencies_mixin.pyi +45 -0
  145. amsdal/schemas/mixins/verify_schemas_mixin.py +96 -0
  146. amsdal/schemas/mixins/verify_schemas_mixin.pyi +33 -0
  147. amsdal/schemas/repository.py +84 -0
  148. amsdal/schemas/repository.pyi +22 -0
  149. amsdal/schemas/utils.py +16 -0
  150. amsdal/schemas/utils.pyi +10 -0
  151. amsdal/services/__init__.py +11 -0
  152. amsdal/services/__init__.pyi +4 -0
  153. amsdal/services/external_connections.py +262 -0
  154. amsdal/services/external_connections.pyi +190 -0
  155. amsdal/services/external_model_generator.py +350 -0
  156. amsdal/services/external_model_generator.pyi +134 -0
  157. amsdal/services/transaction_execution.cpython-311-darwin.so +0 -0
  158. amsdal/services/transaction_execution.pyi +2 -1
  159. amsdal/storages/__init__.py +20 -0
  160. amsdal/storages/__init__.pyi +8 -0
  161. amsdal/storages/file_system.py +214 -0
  162. amsdal/storages/file_system.pyi +36 -0
  163. amsdal/transactions/__init__.py +13 -0
  164. amsdal/transactions/__init__.pyi +4 -0
  165. amsdal/utils/rollback/__init__.py +99 -54
  166. amsdal/utils/rollback/__init__.pyi +6 -0
  167. amsdal/utils/tests/enums.py +0 -2
  168. amsdal/utils/tests/helpers.py +253 -231
  169. amsdal/utils/tests/migrations.py +157 -0
  170. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/METADATA +17 -10
  171. amsdal-0.5.29.dist-info/RECORD +276 -0
  172. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
  173. amsdal/__migrations__/0001_datetime_type.py +0 -18
  174. amsdal/__migrations__/0002_fixture_order.py +0 -40
  175. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -56
  176. amsdal/contrib/auth/models/login_session/hooks/pre_init.py +0 -68
  177. amsdal/contrib/auth/models/login_session/model.json +0 -23
  178. amsdal/contrib/auth/models/login_session/modifiers/display_name.py +0 -11
  179. amsdal/contrib/auth/models/permission/fixtures/basic_permissions.json +0 -62
  180. amsdal/contrib/auth/models/permission/model.json +0 -18
  181. amsdal/contrib/auth/models/permission/modifiers/display_name.py +0 -11
  182. amsdal/contrib/auth/models/user/hooks/post_init.py +0 -76
  183. amsdal/contrib/auth/models/user/hooks/pre_create.py +0 -8
  184. amsdal/contrib/auth/models/user/model.json +0 -25
  185. amsdal/contrib/auth/models/user/modifiers/display_name.py +0 -19
  186. amsdal/contrib/frontend_configs/models/frontend_activator_config/model.json +0 -11
  187. amsdal/contrib/frontend_configs/models/frontend_config_async_validator/model.json +0 -11
  188. amsdal/contrib/frontend_configs/models/frontend_config_group_validator/model.json +0 -52
  189. amsdal/contrib/frontend_configs/models/frontend_config_option/model.json +0 -15
  190. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/model.json +0 -6
  191. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/properties/model_dump.py +0 -13
  192. amsdal/contrib/frontend_configs/models/frontend_config_slider_option/model.json +0 -19
  193. amsdal/contrib/frontend_configs/models/frontend_config_text_mask/model.json +0 -26
  194. amsdal/contrib/frontend_configs/models/frontend_config_validator/model.json +0 -41
  195. amsdal/contrib/frontend_configs/models/frontend_control_config/model.json +0 -250
  196. amsdal/contrib/frontend_configs/models/frontend_model_config/fixtures/permissions.json +0 -24
  197. amsdal/contrib/frontend_configs/models/frontend_model_config/model.json +0 -17
  198. amsdal/contrib/frontend_configs/models/frontent_config_control_action/model.json +0 -54
  199. amsdal/contrib/frontend_configs/models/frontent_config_control_action/properties/action_validate.py +0 -33
  200. amsdal/migration/__init__.cpython-311-darwin.so +0 -0
  201. amsdal/migration/base_migration_schemas.cpython-311-darwin.so +0 -0
  202. amsdal/migration/base_migration_schemas.pyi +0 -120
  203. amsdal/migration/data_classes.cpython-311-darwin.so +0 -0
  204. amsdal/migration/data_classes.pyi +0 -172
  205. amsdal/migration/executors/__init__.cpython-311-darwin.so +0 -0
  206. amsdal/migration/executors/base.cpython-311-darwin.so +0 -0
  207. amsdal/migration/executors/base.pyi +0 -118
  208. amsdal/migration/executors/default_executor.cpython-311-darwin.so +0 -0
  209. amsdal/migration/executors/default_executor.pyi +0 -184
  210. amsdal/migration/executors/state_executor.cpython-311-darwin.so +0 -0
  211. amsdal/migration/executors/state_executor.pyi +0 -78
  212. amsdal/migration/file_migration_executor.cpython-311-darwin.so +0 -0
  213. amsdal/migration/file_migration_executor.pyi +0 -68
  214. amsdal/migration/file_migration_generator.cpython-311-darwin.so +0 -0
  215. amsdal/migration/file_migration_generator.pyi +0 -139
  216. amsdal/migration/file_migration_store.cpython-311-darwin.so +0 -0
  217. amsdal/migration/file_migration_store.pyi +0 -61
  218. amsdal/migration/file_migration_writer.cpython-311-darwin.so +0 -0
  219. amsdal/migration/file_migration_writer.pyi +0 -73
  220. amsdal/migration/migrations.cpython-311-darwin.so +0 -0
  221. amsdal/migration/migrations.pyi +0 -166
  222. amsdal/migration/migrations_loader.cpython-311-darwin.so +0 -0
  223. amsdal/migration/migrations_loader.pyi +0 -32
  224. amsdal/migration/schemas_loaders.cpython-311-darwin.so +0 -0
  225. amsdal/migration/schemas_loaders.pyi +0 -37
  226. amsdal/migration/templates/data_migration.tmpl +0 -18
  227. amsdal/migration/templates/dict_validator.tmpl +0 -4
  228. amsdal/migration/templates/migration.tmpl +0 -6
  229. amsdal/migration/templates/model_class.tmpl +0 -8
  230. amsdal/migration/templates/model_class_layout.tmpl +0 -24
  231. amsdal/migration/templates/options_validator.tmpl +0 -4
  232. amsdal/migration/utils.cpython-311-darwin.so +0 -0
  233. amsdal/migration/utils.pyi +0 -58
  234. amsdal/mixins/build_mixin.cpython-311-darwin.so +0 -0
  235. amsdal/mixins/build_mixin.pyi +0 -78
  236. amsdal/schemas/core/class_object_meta/model.json +0 -59
  237. amsdal/schemas/core/class_property_meta/model.json +0 -23
  238. amsdal/services/__init__.cpython-311-darwin.so +0 -0
  239. amsdal-0.3.3.dist-info/RECORD +0 -257
  240. amsdal-0.3.3.dist-info/licenses/LICENSE.txt +0 -107
  241. /amsdal/{migration → contrib/auth/services}/__init__.pyi +0 -0
  242. /amsdal/{migration/executors → contrib/auth/transactions}/__init__.pyi +0 -0
  243. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info/licenses}/LICENSE.txt +0 -0
  244. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,235 @@
1
+ import typing as t
2
+ from datetime import UTC
3
+ from datetime import datetime
4
+ from datetime import timedelta
5
+ from typing import Any
6
+ from typing import ClassVar
7
+
8
+ import jwt
9
+ from amsdal_models.classes.model import Model
10
+ from amsdal_utils.models.enums import ModuleType
11
+ from pydantic.fields import Field
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
+
20
+
21
+ class LoginSession(Model):
22
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
23
+ email: str = Field(title='Email')
24
+ password: str = Field(title='Password (hash)')
25
+ token: str | None = Field(None, title='Token')
26
+ mfa_code: str | None = Field(None, title='MFA Code')
27
+
28
+ @property
29
+ def display_name(self) -> str:
30
+ """
31
+ Returns the display name of the user.
32
+
33
+ This method returns the email of the user as their display name.
34
+
35
+ Returns:
36
+ str: The email of the user.
37
+ """
38
+ return self.email
39
+
40
+ def pre_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
41
+ """
42
+ Pre-initializes a user object by validating email and password, and generating a JWT token.
43
+
44
+ This method checks if the object is new and validates the provided email and password.
45
+ If the email and password are valid, it generates a JWT token and adds it to the kwargs.
46
+
47
+ Args:
48
+ is_new_object (bool): Indicates if the object is new.
49
+ kwargs (dict[str, Any]): The keyword arguments containing user details.
50
+
51
+ Raises:
52
+ AuthenticationError: If the email or password is invalid.
53
+ """
54
+ if not is_new_object or '_metadata' in kwargs:
55
+ return
56
+ from amsdal.contrib.auth.errors import AuthenticationError
57
+ from amsdal.contrib.auth.settings import auth_settings
58
+
59
+ email = kwargs.get('email', None)
60
+ password = kwargs.get('password', None)
61
+ if not email:
62
+ msg = "Email can't be empty"
63
+ raise AuthenticationError(msg)
64
+ if not password:
65
+ msg = "Password can't be empty"
66
+ raise AuthenticationError(msg)
67
+ lowercased_email = email.lower()
68
+ kwargs['email'] = lowercased_email
69
+
70
+ if not auth_settings.AUTH_JWT_KEY:
71
+ msg = 'JWT key is not set'
72
+ raise AuthenticationError(msg)
73
+
74
+ expiration_time = datetime.now(tz=UTC) + timedelta(seconds=auth_settings.AUTH_TOKEN_EXPIRATION)
75
+ token = jwt.encode(
76
+ {'email': lowercased_email, 'exp': expiration_time},
77
+ key=auth_settings.AUTH_JWT_KEY, # type: ignore[arg-type]
78
+ algorithm='HS256',
79
+ )
80
+ kwargs['token'] = token
81
+
82
+ def pre_create(self) -> None:
83
+ import bcrypt
84
+
85
+ from amsdal.contrib.auth.errors import AuthenticationError
86
+ from amsdal.contrib.auth.errors import InvalidMFACodeError
87
+ from amsdal.contrib.auth.errors import MFARequiredError
88
+ from amsdal.contrib.auth.models.user import User
89
+
90
+ user = User.objects.filter(email=self.email).latest().first().execute()
91
+
92
+ if not user:
93
+ msg = 'User not found'
94
+ raise AuthenticationError(msg)
95
+
96
+ if not bcrypt.checkpw(self.password.encode(), user.password):
97
+ msg = 'Invalid password'
98
+ raise AuthenticationError(msg)
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
+
111
+ self.password = 'validated'
112
+
113
+ def pre_update(self) -> None:
114
+ from amsdal.contrib.auth.errors import AuthenticationError
115
+
116
+ msg = 'Update not allowed'
117
+ raise AuthenticationError(msg)
118
+
119
+ async def apre_create(self) -> None:
120
+ import bcrypt
121
+
122
+ from amsdal.contrib.auth.errors import AuthenticationError
123
+ from amsdal.contrib.auth.errors import InvalidMFACodeError
124
+ from amsdal.contrib.auth.errors import MFARequiredError
125
+ from amsdal.contrib.auth.models.user import User
126
+
127
+ user = await User.objects.filter(email=self.email).latest().first().aexecute()
128
+
129
+ if not user:
130
+ msg = 'User not found'
131
+ raise AuthenticationError(msg)
132
+
133
+ if not bcrypt.checkpw(self.password.encode(), user.password):
134
+ msg = 'Invalid password'
135
+ raise AuthenticationError(msg)
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
+
149
+ self.password = 'validated'
150
+
151
+ async def apre_update(self) -> None:
152
+ from amsdal.contrib.auth.errors import AuthenticationError
153
+
154
+ msg = 'Update not allowed'
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
@@ -0,0 +1,86 @@
1
+ from datetime import UTC
2
+ from datetime import datetime
3
+ from typing import ClassVar
4
+
5
+ from amsdal_models.classes.model import Model
6
+ from amsdal_utils.models.enums import ModuleType
7
+ from pydantic.fields import Field
8
+
9
+ from amsdal.contrib.auth.utils.mfa import DeviceType
10
+
11
+
12
+ def _now_utc() -> datetime:
13
+ """Return current UTC datetime."""
14
+ return datetime.now(tz=UTC)
15
+
16
+
17
+ class MFADevice(Model):
18
+ """
19
+ Base model for Multi-Factor Authentication devices.
20
+
21
+ This model serves as the base class for all MFA device types (TOTP, Backup Codes, Email, SMS).
22
+ Each device is associated with a user and must be confirmed before it can be used for authentication.
23
+
24
+ Attributes:
25
+ user_email (str): Email of the user who owns this device (reference to User).
26
+ device_type (str): Type of MFA device ('totp', 'backup_code', 'email', 'sms').
27
+ name (str): User-friendly name for the device.
28
+ is_active (bool): Whether the device is currently active and can be used.
29
+ confirmed (bool): Whether the device has been verified during setup.
30
+ created_at (datetime): When the device was created.
31
+ last_used_at (datetime | None): When the device was last used for authentication.
32
+ """
33
+
34
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
35
+
36
+ user_email: str = Field(title='User Email')
37
+ device_type: DeviceType | None = Field(default=None, title='Device Type')
38
+ name: str = Field(title='Device Name')
39
+ is_active: bool = Field(True, title='Is Active')
40
+ confirmed: bool = Field(False, title='Confirmed')
41
+ created_at: datetime = Field(default_factory=_now_utc, title='Created At')
42
+ last_used_at: datetime | None = Field(None, title='Last Used At')
43
+
44
+ @property
45
+ def display_name(self) -> str:
46
+ """
47
+ Returns the display name of the device.
48
+
49
+ Returns:
50
+ str: The device name and type.
51
+ """
52
+ return f'{self.name} ({self.device_type})'
53
+
54
+ def __repr__(self) -> str:
55
+ return str(self)
56
+
57
+ def __str__(self) -> str:
58
+ return f'MFADevice(name={self.name}, type={self.device_type}, user={self.user_email})'
59
+
60
+ def has_object_permission(self, user: 'User', action: str) -> bool: # type: ignore # noqa: F821
61
+ """
62
+ Check if a user has permission to perform an action on this device.
63
+
64
+ Users can only manage their own devices. Admins with wildcard permissions
65
+ can manage all devices.
66
+
67
+ Args:
68
+ user: The user requesting the action.
69
+ action: The action being requested (read, update, delete, etc.).
70
+
71
+ Returns:
72
+ bool: True if the user has permission, False otherwise.
73
+ """
74
+ # Users can only manage their own devices
75
+ if self.user_email == user.email:
76
+ return True
77
+
78
+ # Check if user has admin permissions (wildcard model permissions)
79
+ if user.permissions:
80
+ for permission in user.permissions:
81
+ if permission.model == '*' and permission.action in ('*', action):
82
+ return True
83
+ if permission.model == 'MFADevice' and permission.action in ('*', action):
84
+ return True
85
+
86
+ return False
@@ -0,0 +1,23 @@
1
+ from typing import ClassVar
2
+
3
+ from amsdal_models.classes.model import Model
4
+ from amsdal_utils.models.enums import ModuleType
5
+ from pydantic.fields import Field
6
+
7
+
8
+ class Permission(Model):
9
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
10
+ model: str = Field(title='Model')
11
+ action: str = Field(title='Action')
12
+
13
+ @property
14
+ def display_name(self) -> str:
15
+ """
16
+ Returns the display name of the user.
17
+
18
+ This method returns a formatted string combining the model and action of the user.
19
+
20
+ Returns:
21
+ str: The formatted display name in the format 'model:action'.
22
+ """
23
+ return f'{self.model}:{self.action}'
@@ -0,0 +1,113 @@
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 SMSDevice(MFADevice):
13
+ """
14
+ SMS-based MFA device model (future implementation).
15
+
16
+ This model represents an SMS-based MFA method where a temporary code is sent
17
+ to the user's phone number for authentication.
18
+
19
+ Note:
20
+ This is a placeholder for future SMS support. Full implementation requires
21
+ integration with an SMS service provider (e.g., Twilio, AWS SNS).
22
+
23
+ Attributes:
24
+ phone_number (str): The phone number to send codes to.
25
+ code (str | None): Temporary MFA code (stored temporarily, expires after use).
26
+ code_expires_at (datetime | None): When the current code expires.
27
+ """
28
+
29
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
30
+
31
+ phone_number: str = Field(title='Phone Number')
32
+ code: str | None = Field(None, title='Current Code')
33
+ code_expires_at: datetime | None = Field(None, title='Code Expiration')
34
+
35
+ def post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
36
+ """
37
+ Post-initializes an SMS MFA device by setting the device type.
38
+
39
+ Args:
40
+ is_new_object (bool): Indicates if the object is new.
41
+ kwargs (dict[str, Any]): The keyword arguments containing device details.
42
+ """
43
+ super().post_init(is_new_object=is_new_object, kwargs=kwargs)
44
+ self.device_type = DeviceType.SMS
45
+
46
+ def generate_and_send_code(self) -> str:
47
+ """
48
+ Generate a new MFA code and send it via SMS.
49
+
50
+ This method generates a random numeric code, sets its expiration time,
51
+ and sends it to the configured phone number.
52
+
53
+ Returns:
54
+ str: The generated code (for testing purposes).
55
+
56
+ Raises:
57
+ NotImplementedError: This feature requires SMS service integration.
58
+
59
+ Note:
60
+ Full implementation requires integration with an SMS provider.
61
+ Example providers: Twilio, AWS SNS, MessageBird, etc.
62
+ """
63
+ from amsdal.contrib.auth.utils.mfa import generate_email_mfa_code
64
+ from amsdal.contrib.auth.utils.mfa import get_email_code_expiration
65
+
66
+ # Generate new code
67
+ self.code = generate_email_mfa_code()
68
+ self.code_expires_at = get_email_code_expiration()
69
+
70
+ # TODO: Implement SMS sending with your SMS provider
71
+ # Example with Twilio:
72
+ # from twilio.rest import Client
73
+ # client = Client(account_sid, auth_token)
74
+ # client.messages.create(
75
+ # to=self.phone_number,
76
+ # from_=your_twilio_number,
77
+ # body=f'Your MFA code is: {self.code}'
78
+ # )
79
+
80
+ msg = 'SMS MFA is not yet implemented. Please integrate with an SMS service provider (e.g., Twilio, AWS SNS).'
81
+ raise NotImplementedError(msg)
82
+
83
+ def verify_code(self, code: str) -> bool:
84
+ """
85
+ Verify an SMS MFA code.
86
+
87
+ Args:
88
+ code (str): The code to verify.
89
+
90
+ Returns:
91
+ bool: True if the code is valid and not expired, False otherwise.
92
+ """
93
+ from amsdal.contrib.auth.utils.mfa import is_email_code_valid
94
+
95
+ # Check if code matches
96
+ if not self.code or self.code != code:
97
+ return False
98
+
99
+ # Check if code is expired
100
+ if not self.code_expires_at or not is_email_code_valid(self.code_expires_at):
101
+ return False
102
+
103
+ return True
104
+
105
+ def clear_code(self) -> None:
106
+ """
107
+ Clear the current code after successful use or expiration.
108
+ """
109
+ self.code = None
110
+ self.code_expires_at = None
111
+
112
+ def __str__(self) -> str:
113
+ return f'SMSDevice(name={self.name}, phone={self.phone_number}, user={self.user_email})'
@@ -0,0 +1,58 @@
1
+ from typing import Any
2
+ from typing import ClassVar
3
+
4
+ from amsdal_utils.models.enums import ModuleType
5
+ from pydantic.fields import Field
6
+
7
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
8
+ from amsdal.contrib.auth.utils.mfa import DeviceType
9
+
10
+
11
+ class TOTPDevice(MFADevice):
12
+ """
13
+ Time-based One-Time Password (TOTP) device model.
14
+
15
+ This model represents an authenticator app device (e.g., Google Authenticator, Authy)
16
+ that generates time-based one-time passwords following RFC 6238.
17
+
18
+ Attributes:
19
+ secret (str): The encrypted TOTP secret key shared with the authenticator app.
20
+ qr_code_url (str | None): URL for the QR code used during device setup.
21
+ digits (int): Number of digits in the generated code (default: 6).
22
+ step (int): Time step in seconds for code generation (default: 30).
23
+ """
24
+
25
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
26
+
27
+ secret: str = Field(title='TOTP Secret')
28
+ qr_code_url: str | None = Field(None, title='QR Code URL')
29
+ digits: int = Field(6, title='Code Digits')
30
+ step: int = Field(30, title='Time Step (seconds)')
31
+
32
+ def post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
33
+ """
34
+ Post-initializes a TOTP device by setting the device type.
35
+
36
+ Args:
37
+ is_new_object (bool): Indicates if the object is new.
38
+ kwargs (dict[str, Any]): The keyword arguments containing device details.
39
+ """
40
+ super().post_init(is_new_object=is_new_object, kwargs=kwargs)
41
+ self.device_type = DeviceType.TOTP
42
+
43
+ def verify_code(self, code: str) -> bool:
44
+ """
45
+ Verify a TOTP code against this device's secret.
46
+
47
+ Args:
48
+ code (str): The TOTP code to verify.
49
+
50
+ Returns:
51
+ bool: True if the code is valid, False otherwise.
52
+ """
53
+ from amsdal.contrib.auth.utils.mfa import verify_totp_code
54
+
55
+ return verify_totp_code(self.secret, code, self.digits, self.step)
56
+
57
+ def __str__(self) -> str:
58
+ return f'TOTPDevice(name={self.name}, user={self.user_email})'
@@ -0,0 +1,156 @@
1
+ from typing import Any
2
+ from typing import ClassVar
3
+
4
+ from amsdal_models.classes.model import Model
5
+ from amsdal_utils.models.enums import ModuleType
6
+ from pydantic.fields import Field
7
+
8
+ from amsdal.contrib.auth.models.permission import * # noqa: F403
9
+
10
+
11
+ class User(Model):
12
+ __module_type__: ClassVar[ModuleType] = ModuleType.CONTRIB
13
+ email: str = Field(title='Email')
14
+ password: bytes = Field(title='Password (hash)')
15
+ permissions: list['Permission'] | None = Field(None, title='Permissions') # noqa: F405
16
+
17
+ def __repr__(self) -> str:
18
+ return str(self)
19
+
20
+ def __str__(self) -> str:
21
+ return f'User(email={self.email})'
22
+
23
+ async def apre_update(self) -> None:
24
+ import bcrypt
25
+
26
+ original_object = await self.arefetch_from_db()
27
+ password = self.password
28
+ if original_object.password and password is not None:
29
+ if isinstance(password, str):
30
+ password = password.encode('utf-8')
31
+ try:
32
+ if not bcrypt.checkpw(password, original_object.password):
33
+ self.password = password
34
+ except ValueError:
35
+ hashed_password = bcrypt.hashpw(password, bcrypt.gensalt())
36
+ self.password = hashed_password
37
+
38
+ @property
39
+ def display_name(self) -> str:
40
+ """
41
+ Returns the display name of the user.
42
+
43
+ This method returns the email of the user as their display name.
44
+
45
+ Returns:
46
+ str: The email of the user.
47
+ """
48
+ return self.email
49
+
50
+ @property
51
+ def requires_mfa(self) -> bool:
52
+ """
53
+ Determines if MFA is required for this user.
54
+
55
+ This checks both the per-user override (mfa_required) and the global
56
+ REQUIRE_MFA_BY_DEFAULT setting.
57
+
58
+ Returns:
59
+ bool: True if MFA is required, False otherwise.
60
+ """
61
+ from amsdal.contrib.auth.settings import auth_settings
62
+
63
+ # Fall back to global setting
64
+ return auth_settings.REQUIRE_MFA_BY_DEFAULT
65
+
66
+ async def ahas_valid_mfa_device(self) -> bool:
67
+ """
68
+ Check if the user has at least one confirmed and active MFA device.
69
+
70
+ Returns:
71
+ bool: True if the user has a valid MFA device, False otherwise.
72
+ """
73
+ from amsdal.contrib.auth.utils.mfa import aget_active_user_devices
74
+
75
+ devices = await aget_active_user_devices(self)
76
+ for device_list in devices.values():
77
+ if device_list:
78
+ return True
79
+ return False
80
+
81
+ def has_valid_mfa_device(self) -> bool:
82
+ """
83
+ Check if the user has at least one confirmed and active MFA device (sync version).
84
+
85
+ Returns:
86
+ bool: True if the user has a valid MFA device, False otherwise.
87
+ """
88
+ from amsdal.contrib.auth.utils.mfa import get_active_user_devices
89
+
90
+ devices = get_active_user_devices(self)
91
+ for device_list in devices.values():
92
+ if device_list:
93
+ return True
94
+ return False
95
+
96
+ def pre_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None: # noqa: ARG002
97
+ if 'email' in kwargs and isinstance(kwargs['email'], str):
98
+ kwargs['email'] = kwargs['email'].lower()
99
+
100
+ def post_init(self, *, is_new_object: bool, kwargs: dict[str, Any]) -> None:
101
+ """
102
+ Post-initializes a user object by validating email and password, and hashing the password.
103
+
104
+ This method checks if the email and password are provided and valid. If the object is new,
105
+ it hashes the password and sets the object ID to the lowercased email.
106
+
107
+ Args:
108
+ is_new_object (bool): Indicates if the object is new.
109
+ kwargs (dict[str, Any]): The keyword arguments containing user details.
110
+
111
+ Raises:
112
+ UserCreationError: If the email or password is invalid.
113
+ """
114
+ import bcrypt
115
+
116
+ from amsdal.contrib.auth.errors import UserCreationError
117
+
118
+ email = kwargs.get('email', None)
119
+ password = kwargs.get('password', None)
120
+ if email is None or email == '':
121
+ msg = "Email can't be empty"
122
+ raise UserCreationError(msg)
123
+ if password is None or password == '':
124
+ msg = "Password can't be empty"
125
+ raise UserCreationError(msg)
126
+ kwargs['email'] = email.lower()
127
+ if is_new_object and '_metadata' not in kwargs:
128
+ if isinstance(password, str):
129
+ password = password.encode('utf-8')
130
+ hashed_password = bcrypt.hashpw(password, bcrypt.gensalt())
131
+ self.password = hashed_password
132
+ self._object_id = email.lower()
133
+
134
+ def pre_create(self) -> None:
135
+ """
136
+ Pre-creates a user object.
137
+
138
+ This method is a placeholder for any pre-creation logic that needs to be executed
139
+ before a user object is created.
140
+ """
141
+ pass
142
+
143
+ def pre_update(self) -> None:
144
+ import bcrypt
145
+
146
+ original_object = self.refetch_from_db()
147
+ password = self.password
148
+ if original_object.password and password is not None:
149
+ if isinstance(password, str):
150
+ password = password.encode('utf-8')
151
+ try:
152
+ if not bcrypt.checkpw(password, original_object.password):
153
+ self.password = password
154
+ except ValueError:
155
+ hashed_password = bcrypt.hashpw(password, bcrypt.gensalt())
156
+ self.password = hashed_password
@@ -0,0 +1 @@
1
+ """MFA device management services."""