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,216 @@
|
|
|
1
|
+
from amsdal.contrib.auth.errors import MFADeviceNotFoundError as MFADeviceNotFoundError, PermissionDeniedError as PermissionDeniedError, UserNotFoundError as UserNotFoundError
|
|
2
|
+
from amsdal.contrib.auth.models.backup_code import BackupCode as BackupCode
|
|
3
|
+
from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice as EmailMFADevice
|
|
4
|
+
from amsdal.contrib.auth.models.mfa_device import MFADevice as MFADevice
|
|
5
|
+
from amsdal.contrib.auth.models.user import User as User
|
|
6
|
+
from amsdal.contrib.auth.settings import auth_settings as auth_settings
|
|
7
|
+
from amsdal.contrib.auth.utils.mfa import DeviceType as DeviceType, generate_backup_codes as generate_backup_codes
|
|
8
|
+
from amsdal_data.transactions.decorators import async_transaction, transaction
|
|
9
|
+
|
|
10
|
+
class MFADeviceService:
|
|
11
|
+
"""Service for general MFA device management operations."""
|
|
12
|
+
@classmethod
|
|
13
|
+
def _is_admin(cls, user: User) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Check if user has admin permissions (wildcard or MFADevice-specific).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
user: The user to check.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
bool: True if user has admin permissions, False otherwise.
|
|
22
|
+
"""
|
|
23
|
+
@classmethod
|
|
24
|
+
def _check_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Check if current_user has permission for action on target_user.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
current_user: The authenticated user making the request.
|
|
30
|
+
target_user_email: Email of the user being targeted.
|
|
31
|
+
action: The action being performed.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
PermissionDeniedError: If user lacks permission.
|
|
35
|
+
"""
|
|
36
|
+
@classmethod
|
|
37
|
+
async def _acheck_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Async version of _check_permission.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
current_user: The authenticated user making the request.
|
|
43
|
+
target_user_email: Email of the user being targeted.
|
|
44
|
+
action: The action being performed.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
PermissionDeniedError: If user lacks permission.
|
|
48
|
+
"""
|
|
49
|
+
@classmethod
|
|
50
|
+
@transaction
|
|
51
|
+
def add_email_device(cls, current_user: User, target_user_email: str, device_name: str, email: str | None = None) -> EmailMFADevice:
|
|
52
|
+
"""
|
|
53
|
+
Add email MFA device for a user.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
current_user: The authenticated user making the request.
|
|
57
|
+
target_user_email: Email of user to add device for.
|
|
58
|
+
device_name: User-friendly name for the device.
|
|
59
|
+
email: Email for MFA codes (defaults to target_user_email).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
EmailMFADevice: The created device.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
UserNotFoundError: If target user doesn't exist.
|
|
66
|
+
PermissionDeniedError: If user lacks permission.
|
|
67
|
+
"""
|
|
68
|
+
@classmethod
|
|
69
|
+
@async_transaction
|
|
70
|
+
async def aadd_email_device(cls, current_user: User, target_user_email: str, device_name: str, email: str | None = None) -> EmailMFADevice:
|
|
71
|
+
"""
|
|
72
|
+
Async version of add_email_device.
|
|
73
|
+
|
|
74
|
+
Add email MFA device for a user.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
current_user: The authenticated user making the request.
|
|
78
|
+
target_user_email: Email of user to add device for.
|
|
79
|
+
device_name: User-friendly name for the device.
|
|
80
|
+
email: Email for MFA codes (defaults to target_user_email).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
EmailMFADevice: The created device.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
UserNotFoundError: If target user doesn't exist.
|
|
87
|
+
PermissionDeniedError: If user lacks permission.
|
|
88
|
+
"""
|
|
89
|
+
@classmethod
|
|
90
|
+
@transaction
|
|
91
|
+
def add_backup_codes(cls, current_user: User, target_user_email: str, device_name: str = 'Backup Codes', code_count: int | None = None) -> tuple[list[BackupCode], list[str]]:
|
|
92
|
+
"""
|
|
93
|
+
Add backup codes for a user.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
current_user: The authenticated user making the request.
|
|
97
|
+
target_user_email: Email of user to add codes for.
|
|
98
|
+
device_name: Name for the backup code set.
|
|
99
|
+
code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
UserNotFoundError: If target user doesn't exist.
|
|
106
|
+
PermissionDeniedError: If user lacks permission.
|
|
107
|
+
|
|
108
|
+
Security Note:
|
|
109
|
+
Plaintext codes are returned ONLY during creation.
|
|
110
|
+
Caller must display/send these to user immediately.
|
|
111
|
+
Codes cannot be retrieved later.
|
|
112
|
+
"""
|
|
113
|
+
@classmethod
|
|
114
|
+
@async_transaction
|
|
115
|
+
async def aadd_backup_codes(cls, current_user: User, target_user_email: str, device_name: str = 'Backup Codes', code_count: int | None = None) -> tuple[list[BackupCode], list[str]]:
|
|
116
|
+
"""
|
|
117
|
+
Async version of add_backup_codes.
|
|
118
|
+
|
|
119
|
+
Add backup codes for a user.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
current_user: The authenticated user making the request.
|
|
123
|
+
target_user_email: Email of user to add codes for.
|
|
124
|
+
device_name: Name for the backup code set.
|
|
125
|
+
code_count: Number of codes (defaults to MFA_BACKUP_CODES_COUNT setting).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
tuple[list[BackupCode], list[str]]: Device instances and plaintext codes.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
UserNotFoundError: If target user doesn't exist.
|
|
132
|
+
PermissionDeniedError: If user lacks permission.
|
|
133
|
+
|
|
134
|
+
Security Note:
|
|
135
|
+
Plaintext codes are returned ONLY during creation.
|
|
136
|
+
Caller must display/send these to user immediately.
|
|
137
|
+
Codes cannot be retrieved later.
|
|
138
|
+
"""
|
|
139
|
+
@classmethod
|
|
140
|
+
def list_devices(cls, current_user: User, target_user_email: str, *, include_unconfirmed: bool = False) -> dict[DeviceType, list[MFADevice]]:
|
|
141
|
+
"""
|
|
142
|
+
List all MFA devices for a user.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
current_user: The authenticated user making the request.
|
|
146
|
+
target_user_email: Email of user to list devices for.
|
|
147
|
+
include_unconfirmed: Whether to include unconfirmed devices.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
dict[DeviceType, list[MFADevice]]: Devices grouped by type.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
PermissionDeniedError: If user lacks permission.
|
|
154
|
+
|
|
155
|
+
Note: Read-only operation, no transaction needed.
|
|
156
|
+
"""
|
|
157
|
+
@classmethod
|
|
158
|
+
async def alist_devices(cls, current_user: User, target_user_email: str, *, include_unconfirmed: bool = False) -> dict[DeviceType, list[MFADevice]]:
|
|
159
|
+
"""
|
|
160
|
+
Async version of list_devices.
|
|
161
|
+
|
|
162
|
+
List all MFA devices for a user.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
current_user: The authenticated user making the request.
|
|
166
|
+
target_user_email: Email of user to list devices for.
|
|
167
|
+
include_unconfirmed: Whether to include unconfirmed devices.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
dict[DeviceType, list[MFADevice]]: Devices grouped by type.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
PermissionDeniedError: If user lacks permission.
|
|
174
|
+
|
|
175
|
+
Note: Read-only operation, no transaction needed.
|
|
176
|
+
"""
|
|
177
|
+
@classmethod
|
|
178
|
+
@transaction
|
|
179
|
+
def remove_device(cls, current_user: User, device_id: str, *, hard_delete: bool = False) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Remove (deactivate or delete) an MFA device.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
current_user: The authenticated user making the request.
|
|
185
|
+
device_id: ID of the device to remove.
|
|
186
|
+
hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
PermissionDeniedError: If user lacks permission.
|
|
190
|
+
MFADeviceNotFoundError: If device doesn't exist.
|
|
191
|
+
|
|
192
|
+
Security Note:
|
|
193
|
+
Soft delete (default) preserves audit trail.
|
|
194
|
+
Hard delete permanently removes device.
|
|
195
|
+
"""
|
|
196
|
+
@classmethod
|
|
197
|
+
@async_transaction
|
|
198
|
+
async def aremove_device(cls, current_user: User, device_id: str, *, hard_delete: bool = False) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Async version of remove_device.
|
|
201
|
+
|
|
202
|
+
Remove (deactivate or delete) an MFA device.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
current_user: The authenticated user making the request.
|
|
206
|
+
device_id: ID of the device to remove.
|
|
207
|
+
hard_delete: If True, permanently delete; if False, soft delete (mark inactive).
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
PermissionDeniedError: If user lacks permission.
|
|
211
|
+
MFADeviceNotFoundError: If device doesn't exist.
|
|
212
|
+
|
|
213
|
+
Security Note:
|
|
214
|
+
Soft delete (default) preserves audit trail.
|
|
215
|
+
Hard delete permanently removes device.
|
|
216
|
+
"""
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""TOTP device enrollment and confirmation service."""
|
|
2
|
+
|
|
3
|
+
from amsdal_data.transactions.decorators import async_transaction
|
|
4
|
+
from amsdal_data.transactions.decorators import transaction
|
|
5
|
+
from amsdal_utils.models.enums import Versions
|
|
6
|
+
|
|
7
|
+
from amsdal.contrib.auth.errors import InvalidMFACodeError
|
|
8
|
+
from amsdal.contrib.auth.errors import MFADeviceNotFoundError
|
|
9
|
+
from amsdal.contrib.auth.errors import MFASetupError
|
|
10
|
+
from amsdal.contrib.auth.errors import PermissionDeniedError
|
|
11
|
+
from amsdal.contrib.auth.errors import UserNotFoundError
|
|
12
|
+
from amsdal.contrib.auth.models.totp_device import TOTPDevice
|
|
13
|
+
from amsdal.contrib.auth.models.user import User
|
|
14
|
+
from amsdal.contrib.auth.settings import auth_settings
|
|
15
|
+
from amsdal.contrib.auth.utils.mfa import generate_qr_code_url
|
|
16
|
+
from amsdal.contrib.auth.utils.mfa import generate_totp_secret
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TOTPService:
|
|
20
|
+
"""Service for TOTP device two-step enrollment flow."""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def _is_admin(cls, user: User) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Check if user has admin permissions (wildcard or MFADevice-specific).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
user: The user to check.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
bool: True if user has admin permissions, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
if not user.permissions:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
for permission in user.permissions:
|
|
37
|
+
if permission.model == '*' and permission.action == '*':
|
|
38
|
+
return True
|
|
39
|
+
if permission.model == 'MFADevice' and permission.action in ('*', 'create', 'update'):
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _check_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Check if current_user has permission for action on target_user.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
current_user: The authenticated user making the request.
|
|
51
|
+
target_user_email: Email of the user being targeted.
|
|
52
|
+
action: The action being performed.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
PermissionDeniedError: If user lacks permission.
|
|
56
|
+
"""
|
|
57
|
+
# Same user can manage their own devices
|
|
58
|
+
if current_user.email == target_user_email:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Admin can manage any user's devices
|
|
62
|
+
if cls._is_admin(current_user):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
msg = f'User {current_user.email} does not have permission to {action} devices for {target_user_email}'
|
|
66
|
+
raise PermissionDeniedError(msg)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
async def _acheck_permission(cls, current_user: User, target_user_email: str, action: str) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Async version of _check_permission.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
current_user: The authenticated user making the request.
|
|
75
|
+
target_user_email: Email of the user being targeted.
|
|
76
|
+
action: The action being performed.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
PermissionDeniedError: If user lacks permission.
|
|
80
|
+
"""
|
|
81
|
+
# Same implementation as sync version (no async DB calls needed)
|
|
82
|
+
cls._check_permission(current_user, target_user_email, action)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
@transaction
|
|
86
|
+
def setup_totp_device(
|
|
87
|
+
cls,
|
|
88
|
+
current_user: User,
|
|
89
|
+
target_user_email: str,
|
|
90
|
+
device_name: str,
|
|
91
|
+
issuer: str | None = None,
|
|
92
|
+
) -> dict[str, str]:
|
|
93
|
+
"""
|
|
94
|
+
Step 1: Setup TOTP device (generate secret, create unconfirmed device).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
current_user: The authenticated user making the request.
|
|
98
|
+
target_user_email: Email of user to setup device for.
|
|
99
|
+
device_name: User-friendly name for the device.
|
|
100
|
+
issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
dict with keys:
|
|
104
|
+
- secret: Base32 secret (show to user ONCE)
|
|
105
|
+
- qr_code_url: otpauth:// URL for QR code generation
|
|
106
|
+
- device_id: ID of unconfirmed device
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
UserNotFoundError: If target user doesn't exist.
|
|
110
|
+
PermissionDeniedError: If user lacks permission.
|
|
111
|
+
|
|
112
|
+
Security Note:
|
|
113
|
+
Secret is returned ONLY during setup.
|
|
114
|
+
User must scan QR or manually enter secret.
|
|
115
|
+
Device is created with confirmed=False.
|
|
116
|
+
"""
|
|
117
|
+
# Verify target user exists FIRST
|
|
118
|
+
target_user = (
|
|
119
|
+
User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
|
|
120
|
+
.get_or_none()
|
|
121
|
+
.execute()
|
|
122
|
+
)
|
|
123
|
+
if target_user is None:
|
|
124
|
+
msg = f'User with email {target_user_email} not found'
|
|
125
|
+
raise UserNotFoundError(msg)
|
|
126
|
+
|
|
127
|
+
# Check permissions AFTER verifying user exists
|
|
128
|
+
cls._check_permission(current_user, target_user_email, 'create')
|
|
129
|
+
|
|
130
|
+
# Generate TOTP secret
|
|
131
|
+
secret = generate_totp_secret()
|
|
132
|
+
|
|
133
|
+
# Generate QR code URL
|
|
134
|
+
if issuer is None:
|
|
135
|
+
issuer = auth_settings.MFA_TOTP_ISSUER
|
|
136
|
+
|
|
137
|
+
qr_code_url = generate_qr_code_url(secret, target_user_email, issuer)
|
|
138
|
+
|
|
139
|
+
# Create unconfirmed TOTP device
|
|
140
|
+
device = TOTPDevice( # type: ignore[call-arg]
|
|
141
|
+
user_email=target_user_email,
|
|
142
|
+
name=device_name,
|
|
143
|
+
secret=secret,
|
|
144
|
+
qr_code_url=qr_code_url,
|
|
145
|
+
confirmed=False, # Explicitly set as unconfirmed
|
|
146
|
+
)
|
|
147
|
+
device.save(force_insert=True)
|
|
148
|
+
|
|
149
|
+
# Return secret, QR code URL, and device ID
|
|
150
|
+
return {
|
|
151
|
+
'secret': secret,
|
|
152
|
+
'qr_code_url': qr_code_url,
|
|
153
|
+
'device_id': device._object_id,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
@async_transaction
|
|
158
|
+
async def asetup_totp_device(
|
|
159
|
+
cls,
|
|
160
|
+
current_user: User,
|
|
161
|
+
target_user_email: str,
|
|
162
|
+
device_name: str,
|
|
163
|
+
issuer: str | None = None,
|
|
164
|
+
) -> dict[str, str]:
|
|
165
|
+
"""
|
|
166
|
+
Async version of setup_totp_device.
|
|
167
|
+
|
|
168
|
+
Step 1: Setup TOTP device (generate secret, create unconfirmed device).
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
current_user: The authenticated user making the request.
|
|
172
|
+
target_user_email: Email of user to setup device for.
|
|
173
|
+
device_name: User-friendly name for the device.
|
|
174
|
+
issuer: TOTP issuer name (defaults to MFA_TOTP_ISSUER setting).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
dict with keys:
|
|
178
|
+
- secret: Base32 secret (show to user ONCE)
|
|
179
|
+
- qr_code_url: otpauth:// URL for QR code generation
|
|
180
|
+
- device_id: ID of unconfirmed device
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
UserNotFoundError: If target user doesn't exist.
|
|
184
|
+
PermissionDeniedError: If user lacks permission.
|
|
185
|
+
|
|
186
|
+
Security Note:
|
|
187
|
+
Secret is returned ONLY during setup.
|
|
188
|
+
User must scan QR or manually enter secret.
|
|
189
|
+
Device is created with confirmed=False.
|
|
190
|
+
"""
|
|
191
|
+
# Verify target user exists FIRST
|
|
192
|
+
target_user = (
|
|
193
|
+
await User.objects.filter(email=target_user_email, _address__object_version=Versions.LATEST)
|
|
194
|
+
.get_or_none()
|
|
195
|
+
.aexecute()
|
|
196
|
+
)
|
|
197
|
+
if target_user is None:
|
|
198
|
+
msg = f'User with email {target_user_email} not found'
|
|
199
|
+
raise UserNotFoundError(msg)
|
|
200
|
+
|
|
201
|
+
# Check permissions AFTER verifying user exists
|
|
202
|
+
await cls._acheck_permission(current_user, target_user_email, 'create')
|
|
203
|
+
|
|
204
|
+
# Generate TOTP secret
|
|
205
|
+
secret = generate_totp_secret()
|
|
206
|
+
|
|
207
|
+
# Generate QR code URL
|
|
208
|
+
if issuer is None:
|
|
209
|
+
issuer = auth_settings.MFA_TOTP_ISSUER
|
|
210
|
+
|
|
211
|
+
qr_code_url = generate_qr_code_url(secret, target_user_email, issuer)
|
|
212
|
+
|
|
213
|
+
# Create unconfirmed TOTP device
|
|
214
|
+
device = TOTPDevice( # type: ignore[call-arg]
|
|
215
|
+
user_email=target_user_email,
|
|
216
|
+
name=device_name,
|
|
217
|
+
secret=secret,
|
|
218
|
+
qr_code_url=qr_code_url,
|
|
219
|
+
confirmed=False, # Explicitly set as unconfirmed
|
|
220
|
+
)
|
|
221
|
+
await device.asave(force_insert=True)
|
|
222
|
+
|
|
223
|
+
# Return secret, QR code URL, and device ID
|
|
224
|
+
return {
|
|
225
|
+
'secret': secret,
|
|
226
|
+
'qr_code_url': qr_code_url,
|
|
227
|
+
'device_id': device._object_id,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
@transaction
|
|
232
|
+
def confirm_totp_device(
|
|
233
|
+
cls,
|
|
234
|
+
current_user: User,
|
|
235
|
+
device_id: str,
|
|
236
|
+
verification_code: str,
|
|
237
|
+
) -> TOTPDevice:
|
|
238
|
+
"""
|
|
239
|
+
Step 2: Confirm TOTP device by verifying code.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
current_user: The authenticated user making the request.
|
|
243
|
+
device_id: ID of unconfirmed device from setup step.
|
|
244
|
+
verification_code: 6-digit code from authenticator app.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
TOTPDevice: The confirmed device.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
PermissionDeniedError: If user lacks permission.
|
|
251
|
+
MFADeviceNotFoundError: If device doesn't exist.
|
|
252
|
+
InvalidMFACodeError: If verification code is incorrect.
|
|
253
|
+
MFASetupError: If device already confirmed.
|
|
254
|
+
|
|
255
|
+
Flow:
|
|
256
|
+
1. Retrieve unconfirmed device
|
|
257
|
+
2. Check ownership/permissions
|
|
258
|
+
3. Verify code using device.verify_code()
|
|
259
|
+
4. Mark device as confirmed=True
|
|
260
|
+
5. Save and return
|
|
261
|
+
"""
|
|
262
|
+
# Retrieve device by ID
|
|
263
|
+
device = (
|
|
264
|
+
TOTPDevice.objects.filter(_object_id=device_id, _address__object_version=Versions.LATEST)
|
|
265
|
+
.get_or_none()
|
|
266
|
+
.execute()
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if device is None:
|
|
270
|
+
msg = f'TOTP device with ID {device_id} not found'
|
|
271
|
+
raise MFADeviceNotFoundError(msg)
|
|
272
|
+
|
|
273
|
+
# Check permissions (ownership or admin)
|
|
274
|
+
if not device.has_object_permission(current_user, 'update'):
|
|
275
|
+
msg = f'User {current_user.email} does not have permission to confirm device {device_id}'
|
|
276
|
+
raise PermissionDeniedError(msg)
|
|
277
|
+
|
|
278
|
+
# Verify device not already confirmed
|
|
279
|
+
if device.confirmed:
|
|
280
|
+
msg = f'Device {device_id} is already confirmed'
|
|
281
|
+
raise MFASetupError(msg)
|
|
282
|
+
|
|
283
|
+
# Verify code
|
|
284
|
+
if not device.verify_code(verification_code):
|
|
285
|
+
msg = 'Invalid verification code'
|
|
286
|
+
raise InvalidMFACodeError(msg)
|
|
287
|
+
|
|
288
|
+
# Mark as confirmed
|
|
289
|
+
device.confirmed = True
|
|
290
|
+
device.save()
|
|
291
|
+
|
|
292
|
+
return device
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
@async_transaction
|
|
296
|
+
async def aconfirm_totp_device(
|
|
297
|
+
cls,
|
|
298
|
+
current_user: User,
|
|
299
|
+
device_id: str,
|
|
300
|
+
verification_code: str,
|
|
301
|
+
) -> TOTPDevice:
|
|
302
|
+
"""
|
|
303
|
+
Async version of confirm_totp_device.
|
|
304
|
+
|
|
305
|
+
Step 2: Confirm TOTP device by verifying code.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
current_user: The authenticated user making the request.
|
|
309
|
+
device_id: ID of unconfirmed device from setup step.
|
|
310
|
+
verification_code: 6-digit code from authenticator app.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
TOTPDevice: The confirmed device.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
PermissionDeniedError: If user lacks permission.
|
|
317
|
+
MFADeviceNotFoundError: If device doesn't exist.
|
|
318
|
+
InvalidMFACodeError: If verification code is incorrect.
|
|
319
|
+
MFASetupError: If device already confirmed.
|
|
320
|
+
|
|
321
|
+
Flow:
|
|
322
|
+
1. Retrieve unconfirmed device
|
|
323
|
+
2. Check ownership/permissions
|
|
324
|
+
3. Verify code using device.verify_code()
|
|
325
|
+
4. Mark device as confirmed=True
|
|
326
|
+
5. Save and return
|
|
327
|
+
"""
|
|
328
|
+
# Retrieve device by ID
|
|
329
|
+
device = (
|
|
330
|
+
await TOTPDevice.objects.filter(_object_id=device_id, _address__object_version=Versions.LATEST)
|
|
331
|
+
.get_or_none()
|
|
332
|
+
.aexecute()
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if device is None:
|
|
336
|
+
msg = f'TOTP device with ID {device_id} not found'
|
|
337
|
+
raise MFADeviceNotFoundError(msg)
|
|
338
|
+
|
|
339
|
+
# Check permissions (ownership or admin)
|
|
340
|
+
if not device.has_object_permission(current_user, 'update'):
|
|
341
|
+
msg = f'User {current_user.email} does not have permission to confirm device {device_id}'
|
|
342
|
+
raise PermissionDeniedError(msg)
|
|
343
|
+
|
|
344
|
+
# Verify device not already confirmed
|
|
345
|
+
if device.confirmed:
|
|
346
|
+
msg = f'Device {device_id} is already confirmed'
|
|
347
|
+
raise MFASetupError(msg)
|
|
348
|
+
|
|
349
|
+
# Verify code
|
|
350
|
+
if not device.verify_code(verification_code):
|
|
351
|
+
msg = 'Invalid verification code'
|
|
352
|
+
raise InvalidMFACodeError(msg)
|
|
353
|
+
|
|
354
|
+
# Mark as confirmed
|
|
355
|
+
device.confirmed = True
|
|
356
|
+
await device.asave()
|
|
357
|
+
|
|
358
|
+
return device
|