amsdal 0.4.10__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.
- 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-311-darwin.so +0 -0
- amsdal/cloud/client.cpython-311-darwin.so +0 -0
- amsdal/cloud/constants.cpython-311-darwin.so +0 -0
- amsdal/cloud/enums.cpython-311-darwin.so +0 -0
- amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
- amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
- amsdal/configs/main.py +17 -1
- amsdal/configs/main.pyi +7 -3
- amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
- amsdal/contrib/auth/errors.py +36 -0
- amsdal/contrib/auth/errors.pyi +12 -0
- amsdal/contrib/auth/lifecycle/consumer.py +3 -3
- amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
- 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 +463 -0
- amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
- amsdal/contrib/auth/transactions/totp_transactions.py +206 -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 +32 -5
- 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-311-darwin.so +0 -0
- amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
- amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
- amsdal/manager.cpython-311-darwin.so +0 -0
- amsdal/manager.pyi +5 -0
- amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
- amsdal/mixins/class_versions_mixin.cpython-311-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/interfaces.pyi +1 -1
- amsdal/schemas/manager.cpython-311-darwin.so +0 -0
- amsdal/schemas/mixins/check_dependencies_mixin.py +23 -8
- amsdal/schemas/mixins/check_dependencies_mixin.pyi +5 -2
- amsdal/schemas/utils.pyi +2 -2
- 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-311-darwin.so +0 -0
- amsdal/services/transaction_execution.pyi +1 -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/rollback/__init__.pyi +6 -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.10.dist-info → amsdal-0.5.29.dist-info}/METADATA +13 -8
- {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/RECORD +131 -124
- {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
- 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-311-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.10.dist-info → amsdal-0.5.29.dist-info}/licenses/LICENSE.txt +0 -0
- {amsdal-0.4.10.dist-info → amsdal-0.5.29.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for Multi-Factor Authentication (MFA).
|
|
3
|
+
|
|
4
|
+
This module provides helper functions for generating and verifying MFA codes
|
|
5
|
+
for different authentication methods (TOTP, backup codes, email codes).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
import typing as t
|
|
11
|
+
from datetime import UTC
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
|
|
16
|
+
import pyotp
|
|
17
|
+
from amsdal_utils.models.enums import Versions
|
|
18
|
+
|
|
19
|
+
from amsdal.contrib.auth.settings import auth_settings
|
|
20
|
+
|
|
21
|
+
if t.TYPE_CHECKING:
|
|
22
|
+
from amsdal.contrib.auth.models.mfa_device import MFADevice
|
|
23
|
+
from amsdal.contrib.auth.models.user import User
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeviceType(StrEnum):
|
|
27
|
+
TOTP = 'totp'
|
|
28
|
+
BACKUP_CODE = 'backup_code'
|
|
29
|
+
EMAIL = 'email'
|
|
30
|
+
SMS = 'sms'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_active_user_devices(user: 'User') -> dict[DeviceType, list['MFADevice']]:
|
|
34
|
+
from amsdal.contrib.auth.models.backup_code import BackupCode
|
|
35
|
+
from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
|
|
36
|
+
from amsdal.contrib.auth.models.sms_device import SMSDevice
|
|
37
|
+
from amsdal.contrib.auth.models.totp_device import TOTPDevice
|
|
38
|
+
|
|
39
|
+
_result: dict[DeviceType, list[MFADevice]] = {}
|
|
40
|
+
for device_class, device_type in [
|
|
41
|
+
(TOTPDevice, DeviceType.TOTP),
|
|
42
|
+
(BackupCode, DeviceType.BACKUP_CODE),
|
|
43
|
+
(EmailMFADevice, DeviceType.EMAIL),
|
|
44
|
+
(SMSDevice, DeviceType.SMS),
|
|
45
|
+
]:
|
|
46
|
+
devices = device_class.objects.filter( # type: ignore[attr-defined]
|
|
47
|
+
user_email=user.email,
|
|
48
|
+
is_active=True,
|
|
49
|
+
confirmed=True,
|
|
50
|
+
_address__object_version=Versions.LATEST,
|
|
51
|
+
).execute()
|
|
52
|
+
_result[device_type] = devices
|
|
53
|
+
|
|
54
|
+
return _result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def aget_active_user_devices(user: 'User') -> dict[DeviceType, list['MFADevice']]:
|
|
58
|
+
from amsdal.contrib.auth.models.backup_code import BackupCode
|
|
59
|
+
from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
|
|
60
|
+
from amsdal.contrib.auth.models.sms_device import SMSDevice
|
|
61
|
+
from amsdal.contrib.auth.models.totp_device import TOTPDevice
|
|
62
|
+
|
|
63
|
+
_result: dict[DeviceType, list[MFADevice]] = {}
|
|
64
|
+
for device_class, device_type in [
|
|
65
|
+
(TOTPDevice, DeviceType.TOTP),
|
|
66
|
+
(BackupCode, DeviceType.BACKUP_CODE),
|
|
67
|
+
(EmailMFADevice, DeviceType.EMAIL),
|
|
68
|
+
(SMSDevice, DeviceType.SMS),
|
|
69
|
+
]:
|
|
70
|
+
devices = await device_class.objects.filter( # type: ignore[attr-defined]
|
|
71
|
+
user_email=user.email,
|
|
72
|
+
is_active=True,
|
|
73
|
+
confirmed=True,
|
|
74
|
+
_address__object_version=Versions.LATEST,
|
|
75
|
+
).aexecute()
|
|
76
|
+
_result[device_type] = devices
|
|
77
|
+
|
|
78
|
+
return _result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def generate_totp_secret() -> str:
|
|
82
|
+
"""
|
|
83
|
+
Generate a new TOTP secret key.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
str: A base32-encoded random secret key.
|
|
87
|
+
"""
|
|
88
|
+
return pyotp.random_base32()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def generate_qr_code_url(secret: str, email: str, issuer: str | None = None) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Generate a QR code URL for TOTP device setup.
|
|
94
|
+
|
|
95
|
+
This creates an otpauth:// URL that can be scanned by authenticator apps
|
|
96
|
+
like Google Authenticator, Authy, etc.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
secret (str): The TOTP secret key.
|
|
100
|
+
email (str): The user's email address.
|
|
101
|
+
issuer (str | None): The issuer name to display in the app. If None, uses the
|
|
102
|
+
MFA_TOTP_ISSUER setting.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
str: The otpauth:// URL for QR code generation.
|
|
106
|
+
"""
|
|
107
|
+
if issuer is None:
|
|
108
|
+
issuer = auth_settings.MFA_TOTP_ISSUER
|
|
109
|
+
|
|
110
|
+
# Create TOTP object
|
|
111
|
+
totp = pyotp.TOTP(secret)
|
|
112
|
+
|
|
113
|
+
# Generate provisioning URI
|
|
114
|
+
uri = totp.provisioning_uri(
|
|
115
|
+
name=email,
|
|
116
|
+
issuer_name=issuer,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return uri
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_totp_code(
|
|
123
|
+
secret: str,
|
|
124
|
+
code: str,
|
|
125
|
+
digits: int = 6,
|
|
126
|
+
step: int = 30,
|
|
127
|
+
valid_window: int = 1,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Verify a TOTP code against a secret.
|
|
131
|
+
|
|
132
|
+
This function validates the provided code against the TOTP secret, allowing
|
|
133
|
+
for a time window to account for clock drift between the server and the device.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
secret (str): The TOTP secret key.
|
|
137
|
+
code (str): The code to verify.
|
|
138
|
+
digits (int): Number of digits in the code (default: 6).
|
|
139
|
+
step (int): Time step in seconds (default: 30).
|
|
140
|
+
valid_window (int): Number of time steps to check before and after the current time
|
|
141
|
+
(default: 1, which means ±30 seconds with default step).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
bool: True if the code is valid, False otherwise.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
totp = pyotp.TOTP(secret, digits=digits, interval=step)
|
|
148
|
+
return totp.verify(code, valid_window=valid_window)
|
|
149
|
+
except Exception:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generate_backup_codes(count: int | None = None) -> list[str]:
|
|
154
|
+
"""
|
|
155
|
+
Generate a set of backup recovery codes.
|
|
156
|
+
|
|
157
|
+
Each code is a random alphanumeric string that can be used once for authentication
|
|
158
|
+
when the primary MFA device is unavailable.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
count (int | None): Number of codes to generate. If None, uses the
|
|
162
|
+
MFA_BACKUP_CODES_COUNT setting.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
list[str]: List of generated backup codes.
|
|
166
|
+
"""
|
|
167
|
+
if count is None:
|
|
168
|
+
count = auth_settings.MFA_BACKUP_CODES_COUNT
|
|
169
|
+
|
|
170
|
+
codes = []
|
|
171
|
+
for _ in range(count):
|
|
172
|
+
# Generate 8-character alphanumeric code (without ambiguous characters)
|
|
173
|
+
alphabet = string.ascii_uppercase + string.digits
|
|
174
|
+
alphabet = alphabet.replace('O', '').replace('0', '').replace('I', '').replace('1', '')
|
|
175
|
+
code = ''.join(secrets.choice(alphabet) for _ in range(8))
|
|
176
|
+
# Format as XXXX-XXXX for readability
|
|
177
|
+
formatted_code = f'{code[:4]}-{code[4:]}'
|
|
178
|
+
codes.append(formatted_code)
|
|
179
|
+
|
|
180
|
+
return codes
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def hash_backup_code(code: str) -> bytes:
|
|
184
|
+
"""
|
|
185
|
+
Hash a backup code for secure storage.
|
|
186
|
+
|
|
187
|
+
Uses bcrypt for hashing, similar to password hashing.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
code (str): The backup code to hash.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
bytes: The hashed code.
|
|
194
|
+
"""
|
|
195
|
+
import bcrypt
|
|
196
|
+
|
|
197
|
+
# Remove formatting (dashes) before hashing
|
|
198
|
+
clean_code = code.replace('-', '')
|
|
199
|
+
return bcrypt.hashpw(clean_code.encode('utf-8'), bcrypt.gensalt())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def verify_backup_code(hashed_code: bytes, code: str) -> bool:
|
|
203
|
+
"""
|
|
204
|
+
Verify a backup code against its hash.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
hashed_code (bytes): The stored hashed code.
|
|
208
|
+
code (str): The code to verify.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
bool: True if the code matches, False otherwise.
|
|
212
|
+
"""
|
|
213
|
+
import bcrypt
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# Remove formatting (dashes) before verification
|
|
217
|
+
clean_code = code.replace('-', '')
|
|
218
|
+
return bcrypt.checkpw(clean_code.encode('utf-8'), hashed_code)
|
|
219
|
+
except Exception:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def generate_email_mfa_code(length: int = 6) -> str:
|
|
224
|
+
"""
|
|
225
|
+
Generate a random numeric code for email-based MFA.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
length (int): Length of the code (default: 6).
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
str: A random numeric code.
|
|
232
|
+
"""
|
|
233
|
+
return ''.join(secrets.choice(string.digits) for _ in range(length))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_email_code_expiration() -> datetime:
|
|
237
|
+
"""
|
|
238
|
+
Calculate the expiration time for an email MFA code.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
datetime: The expiration timestamp.
|
|
242
|
+
"""
|
|
243
|
+
expiration_seconds = auth_settings.MFA_EMAIL_CODE_EXPIRATION
|
|
244
|
+
return datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def is_email_code_valid(code_expires_at: datetime) -> bool:
|
|
248
|
+
"""
|
|
249
|
+
Check if an email MFA code is still valid (not expired).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
code_expires_at (datetime): The expiration timestamp of the code.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
bool: True if the code is still valid, False if expired.
|
|
256
|
+
"""
|
|
257
|
+
return datetime.now(tz=UTC) < code_expires_at
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from amsdal.contrib.auth.models.mfa_device import MFADevice as MFADevice
|
|
2
|
+
from amsdal.contrib.auth.models.user import User as User
|
|
3
|
+
from amsdal.contrib.auth.settings import auth_settings as auth_settings
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
class DeviceType(StrEnum):
|
|
8
|
+
TOTP = 'totp'
|
|
9
|
+
BACKUP_CODE = 'backup_code'
|
|
10
|
+
EMAIL = 'email'
|
|
11
|
+
SMS = 'sms'
|
|
12
|
+
|
|
13
|
+
def get_active_user_devices(user: User) -> dict[DeviceType, list['MFADevice']]: ...
|
|
14
|
+
async def aget_active_user_devices(user: User) -> dict[DeviceType, list['MFADevice']]: ...
|
|
15
|
+
def generate_totp_secret() -> str:
|
|
16
|
+
"""
|
|
17
|
+
Generate a new TOTP secret key.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
str: A base32-encoded random secret key.
|
|
21
|
+
"""
|
|
22
|
+
def generate_qr_code_url(secret: str, email: str, issuer: str | None = None) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Generate a QR code URL for TOTP device setup.
|
|
25
|
+
|
|
26
|
+
This creates an otpauth:// URL that can be scanned by authenticator apps
|
|
27
|
+
like Google Authenticator, Authy, etc.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
secret (str): The TOTP secret key.
|
|
31
|
+
email (str): The user's email address.
|
|
32
|
+
issuer (str | None): The issuer name to display in the app. If None, uses the
|
|
33
|
+
MFA_TOTP_ISSUER setting.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
str: The otpauth:// URL for QR code generation.
|
|
37
|
+
"""
|
|
38
|
+
def verify_totp_code(secret: str, code: str, digits: int = 6, step: int = 30, valid_window: int = 1) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Verify a TOTP code against a secret.
|
|
41
|
+
|
|
42
|
+
This function validates the provided code against the TOTP secret, allowing
|
|
43
|
+
for a time window to account for clock drift between the server and the device.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
secret (str): The TOTP secret key.
|
|
47
|
+
code (str): The code to verify.
|
|
48
|
+
digits (int): Number of digits in the code (default: 6).
|
|
49
|
+
step (int): Time step in seconds (default: 30).
|
|
50
|
+
valid_window (int): Number of time steps to check before and after the current time
|
|
51
|
+
(default: 1, which means ±30 seconds with default step).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
bool: True if the code is valid, False otherwise.
|
|
55
|
+
"""
|
|
56
|
+
def generate_backup_codes(count: int | None = None) -> list[str]:
|
|
57
|
+
"""
|
|
58
|
+
Generate a set of backup recovery codes.
|
|
59
|
+
|
|
60
|
+
Each code is a random alphanumeric string that can be used once for authentication
|
|
61
|
+
when the primary MFA device is unavailable.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
count (int | None): Number of codes to generate. If None, uses the
|
|
65
|
+
MFA_BACKUP_CODES_COUNT setting.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
list[str]: List of generated backup codes.
|
|
69
|
+
"""
|
|
70
|
+
def hash_backup_code(code: str) -> bytes:
|
|
71
|
+
"""
|
|
72
|
+
Hash a backup code for secure storage.
|
|
73
|
+
|
|
74
|
+
Uses bcrypt for hashing, similar to password hashing.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
code (str): The backup code to hash.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
bytes: The hashed code.
|
|
81
|
+
"""
|
|
82
|
+
def verify_backup_code(hashed_code: bytes, code: str) -> bool:
|
|
83
|
+
"""
|
|
84
|
+
Verify a backup code against its hash.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
hashed_code (bytes): The stored hashed code.
|
|
88
|
+
code (str): The code to verify.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
bool: True if the code matches, False otherwise.
|
|
92
|
+
"""
|
|
93
|
+
def generate_email_mfa_code(length: int = 6) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Generate a random numeric code for email-based MFA.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
length (int): Length of the code (default: 6).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
str: A random numeric code.
|
|
102
|
+
"""
|
|
103
|
+
def get_email_code_expiration() -> datetime:
|
|
104
|
+
"""
|
|
105
|
+
Calculate the expiration time for an email MFA code.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
datetime: The expiration timestamp.
|
|
109
|
+
"""
|
|
110
|
+
def is_email_code_valid(code_expires_at: datetime) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Check if an email MFA code is still valid (not expired).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
code_expires_at (datetime): The expiration timestamp of the code.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
bool: True if the code is still valid, False if expired.
|
|
119
|
+
"""
|
|
@@ -140,6 +140,19 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
|
|
|
140
140
|
return {
|
|
141
141
|
'type': 'text',
|
|
142
142
|
}
|
|
143
|
+
if value.__class__.__name__ == '_LiteralGenericAlias' and hasattr(value, '__origin__'):
|
|
144
|
+
options = get_args(value)
|
|
145
|
+
return {
|
|
146
|
+
'type': 'select',
|
|
147
|
+
'options': [{'label': str(option), 'value': option} for option in options],
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if value.__class__.__name__ == '_AnnotatedAlias' and hasattr(value, '__origin__'):
|
|
151
|
+
# Handle Annotated types
|
|
152
|
+
options = get_args(value)
|
|
153
|
+
if options:
|
|
154
|
+
val = convert_to_frontend_config(options[0], is_transaction=is_transaction)
|
|
155
|
+
return val
|
|
143
156
|
|
|
144
157
|
if isinstance(value, FunctionType):
|
|
145
158
|
function_controls = []
|
|
@@ -207,12 +220,20 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
|
|
|
207
220
|
if issubclass(value, LegacyModel):
|
|
208
221
|
return {}
|
|
209
222
|
|
|
223
|
+
is_timestamp_mixin = False
|
|
210
224
|
if issubclass(value, BaseModel):
|
|
211
225
|
model_controls = []
|
|
212
226
|
|
|
213
227
|
try:
|
|
214
228
|
if issubclass(value, Model | TypeModel):
|
|
215
229
|
schema = model_to_object_schema(value)
|
|
230
|
+
|
|
231
|
+
_mro = value.mro()
|
|
232
|
+
|
|
233
|
+
# remove updated_at and created_at fields
|
|
234
|
+
if any(cls.__name__ == 'TimestampMixin' for cls in _mro):
|
|
235
|
+
is_timestamp_mixin = True
|
|
236
|
+
|
|
216
237
|
except FileNotFoundError:
|
|
217
238
|
schema = None
|
|
218
239
|
|
|
@@ -267,6 +288,9 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
|
|
|
267
288
|
control['required'] = False
|
|
268
289
|
model_controls.append(control)
|
|
269
290
|
|
|
291
|
+
if is_timestamp_mixin:
|
|
292
|
+
model_controls = [c for c in model_controls if c['name'] not in ('created_at', 'updated_at')]
|
|
293
|
+
|
|
270
294
|
return {
|
|
271
295
|
'type': 'group',
|
|
272
296
|
'name': value.__name__,
|
|
@@ -274,10 +298,13 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
|
|
|
274
298
|
'controls': model_controls,
|
|
275
299
|
}
|
|
276
300
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
301
|
+
try:
|
|
302
|
+
if issubclass(value, Enum):
|
|
303
|
+
return {
|
|
304
|
+
'type': 'select',
|
|
305
|
+
'options': [{'label': option.name, 'value': option.value} for option in value],
|
|
306
|
+
}
|
|
307
|
+
except TypeError:
|
|
308
|
+
pass
|
|
282
309
|
|
|
283
310
|
return {}
|