amsdal 0.4.13__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.
- amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +28 -0
- amsdal/__about__.py +1 -1
- amsdal/__migrations__/0000_initial.py +22 -203
- amsdal/__migrations__/0001_create_class_file.py +61 -0
- amsdal/__migrations__/0002_create_class_file.py +109 -0
- amsdal/__migrations__/0003_update_class_file.py +91 -0
- amsdal/__migrations__/0004_update_class_file.py +45 -0
- amsdal/cloud/__init__.cpython-312-darwin.so +0 -0
- amsdal/cloud/client.cpython-312-darwin.so +0 -0
- amsdal/cloud/constants.cpython-312-darwin.so +0 -0
- amsdal/cloud/enums.cpython-312-darwin.so +0 -0
- amsdal/cloud/models/__init__.cpython-312-darwin.so +0 -0
- amsdal/cloud/models/base.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/__init__.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/__init__.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/add_allowlist_ip.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/add_basic_auth.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/add_dependency.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/add_secret.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/base.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/create_deploy.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/create_env.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/create_session.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_allowlist_ip.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_basic_auth.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_dependency.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_env.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_secret.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/destroy_deploy.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/expose_db.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/get_monitoring_info.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/list_dependencies.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/list_deploys.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/list_envs.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/list_secrets.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/manager.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/signup_action.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/actions/update_deploy.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/__init__.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/base.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/credentials.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/manager.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/signup_service.cpython-312-darwin.so +0 -0
- amsdal/cloud/services/auth/token.cpython-312-darwin.so +0 -0
- amsdal/configs/main.py +17 -1
- amsdal/configs/main.pyi +6 -1
- amsdal/contrib/__init__.cpython-312-darwin.so +0 -0
- amsdal/contrib/auth/errors.py +36 -0
- amsdal/contrib/auth/errors.pyi +12 -0
- amsdal/contrib/auth/lifecycle/consumer.py +2 -2
- amsdal/contrib/auth/migrations/0000_initial.py +55 -52
- amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
- amsdal/contrib/auth/models/__init__.py +1 -0
- amsdal/contrib/auth/models/backup_code.py +85 -0
- amsdal/contrib/auth/models/email_mfa_device.py +108 -0
- amsdal/contrib/auth/models/login_session.py +117 -0
- amsdal/contrib/auth/models/mfa_device.py +86 -0
- amsdal/contrib/auth/models/sms_device.py +113 -0
- amsdal/contrib/auth/models/totp_device.py +58 -0
- amsdal/contrib/auth/models/user.py +50 -0
- amsdal/contrib/auth/services/__init__.py +1 -0
- amsdal/contrib/auth/services/mfa_device_service.py +544 -0
- amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
- amsdal/contrib/auth/services/totp_service.py +358 -0
- amsdal/contrib/auth/services/totp_service.pyi +158 -0
- amsdal/contrib/auth/settings.py +8 -0
- amsdal/contrib/auth/settings.pyi +8 -0
- amsdal/contrib/auth/transactions/__init__.py +1 -0
- amsdal/contrib/auth/transactions/mfa_device_transactions.py +458 -0
- amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
- amsdal/contrib/auth/transactions/totp_transactions.py +203 -0
- amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
- amsdal/contrib/auth/utils/mfa.py +257 -0
- amsdal/contrib/auth/utils/mfa.pyi +119 -0
- amsdal/contrib/frontend_configs/conversion/convert.py +24 -0
- amsdal/contrib/frontend_configs/migrations/0000_initial.py +154 -183
- amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
- amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
- amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
- amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +57 -1
- amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
- amsdal/contrib/frontend_configs/models/frontend_control_config.py +69 -46
- amsdal/fixtures/__init__.cpython-312-darwin.so +0 -0
- amsdal/fixtures/manager.cpython-312-darwin.so +0 -0
- amsdal/fixtures/utils.cpython-312-darwin.so +0 -0
- amsdal/manager.cpython-312-darwin.so +0 -0
- amsdal/mixins/__init__.cpython-312-darwin.so +0 -0
- amsdal/mixins/class_versions_mixin.cpython-312-darwin.so +0 -0
- amsdal/models/core/class_object.py +7 -6
- amsdal/models/core/class_property.py +7 -1
- amsdal/models/core/file.py +168 -81
- amsdal/models/core/storage_metadata.py +15 -0
- amsdal/models/mixins.py +31 -0
- amsdal/models/types/object.py +3 -3
- amsdal/schemas/core/class_object/model.json +20 -0
- amsdal/schemas/core/class_property/model.json +19 -0
- amsdal/schemas/core/file/properties/validate_data.py +2 -3
- amsdal/schemas/core/storage_metadata/model.json +52 -0
- amsdal/schemas/manager.cpython-312-darwin.so +0 -0
- amsdal/schemas/mixins/check_dependencies_mixin.py +8 -3
- amsdal/services/__init__.py +11 -0
- amsdal/services/__init__.pyi +4 -0
- amsdal/services/external_connections.py +262 -0
- amsdal/services/external_connections.pyi +190 -0
- amsdal/services/external_model_generator.py +350 -0
- amsdal/services/external_model_generator.pyi +134 -0
- amsdal/services/transaction_execution.cpython-312-darwin.so +0 -0
- amsdal/storages/__init__.py +20 -0
- amsdal/storages/__init__.pyi +8 -0
- amsdal/storages/file_system.py +214 -0
- amsdal/storages/file_system.pyi +36 -0
- amsdal/utils/tests/enums.py +0 -2
- amsdal/utils/tests/helpers.py +213 -381
- amsdal/utils/tests/migrations.py +157 -0
- {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/METADATA +17 -11
- {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/RECORD +124 -117
- amsdal/__migrations__/0001_datetime_type.py +0 -18
- amsdal/__migrations__/0002_fixture_order.py +0 -44
- amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -44
- amsdal/contrib/auth/models/login_session.pyi +0 -37
- amsdal/contrib/auth/models/permission.pyi +0 -18
- amsdal/contrib/auth/models/user.pyi +0 -46
- amsdal/contrib/frontend_configs/models/frontend_activator_config.pyi +0 -12
- amsdal/contrib/frontend_configs/models/frontend_config_async_validator.pyi +0 -7
- amsdal/contrib/frontend_configs/models/frontend_config_control_action.pyi +0 -32
- amsdal/contrib/frontend_configs/models/frontend_config_group_validator.pyi +0 -11
- amsdal/contrib/frontend_configs/models/frontend_config_option.pyi +0 -8
- amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.pyi +0 -8
- amsdal/contrib/frontend_configs/models/frontend_config_slider_option.pyi +0 -9
- amsdal/contrib/frontend_configs/models/frontend_config_text_mask.pyi +0 -10
- amsdal/contrib/frontend_configs/models/frontend_config_validator.pyi +0 -15
- amsdal/contrib/frontend_configs/models/frontend_control_config.pyi +0 -35
- amsdal/contrib/frontend_configs/models/frontend_model_config.pyi +0 -9
- amsdal/models/__init__.pyi +0 -9
- amsdal/models/core/class_object.pyi +0 -24
- amsdal/models/core/class_object_meta.py +0 -26
- amsdal/models/core/class_object_meta.pyi +0 -15
- amsdal/models/core/class_property.pyi +0 -11
- amsdal/models/core/class_property_meta.py +0 -15
- amsdal/models/core/class_property_meta.pyi +0 -10
- amsdal/models/core/file.pyi +0 -104
- amsdal/models/core/fixture.pyi +0 -14
- amsdal/models/core/option.pyi +0 -8
- amsdal/models/core/validator.pyi +0 -8
- amsdal/models/types/object.pyi +0 -16
- amsdal/schemas/core/class_object_meta/model.json +0 -59
- amsdal/schemas/core/class_property_meta/model.json +0 -23
- amsdal/services/__init__.cpython-312-darwin.so +0 -0
- /amsdal/contrib/auth/{models → services}/__init__.pyi +0 -0
- /amsdal/contrib/{frontend_configs/models → auth/transactions}/__init__.pyi +0 -0
- /amsdal/{models/core/__init__.pyi → contrib/auth/utils/__init__.py} +0 -0
- /amsdal/{models/types → contrib/auth/utils}/__init__.pyi +0 -0
- {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/WHEEL +0 -0
- {amsdal-0.4.13.dist-info → amsdal-0.5.33.dist-info}/licenses/LICENSE.txt +0 -0
- {amsdal-0.4.13.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
|