quantumvault-core 0.1.0__py3-none-any.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.
- controllers/README.md +1 -0
- controllers/__init__.py +0 -0
- controllers/auth.py +426 -0
- controllers/contact.py +46 -0
- controllers/cover.py +513 -0
- controllers/deniable_vault.py +476 -0
- controllers/facade.py +381 -0
- controllers/file.py +162 -0
- controllers/message.py +106 -0
- controllers/secure_channel.py +549 -0
- controllers/sync.py +27 -0
- models/README.md +1 -0
- models/__init__.py +0 -0
- models/contact.py +184 -0
- models/deniable_vault.py +108 -0
- models/message.py +195 -0
- models/plans.py +152 -0
- models/superadmin_audit.py +138 -0
- models/user.py +714 -0
- quantumvault_core-0.1.0.dist-info/METADATA +393 -0
- quantumvault_core-0.1.0.dist-info/RECORD +50 -0
- quantumvault_core-0.1.0.dist-info/WHEEL +5 -0
- quantumvault_core-0.1.0.dist-info/licenses/LICENSE +661 -0
- quantumvault_core-0.1.0.dist-info/top_level.txt +4 -0
- utils/README.md +1 -0
- utils/__init__.py +0 -0
- utils/cache.py +22 -0
- utils/integrity.py +91 -0
- utils/mailer.py +88 -0
- utils/padding.py +225 -0
- utils/plans.py +48 -0
- utils/scheduler.py +136 -0
- utils/security.py +219 -0
- utils/srp6a.py +292 -0
- utils/utils.py +185 -0
- views/README.md +1 -0
- views/__init__.py +0 -0
- views/about.py +8 -0
- views/account.py +139 -0
- views/admin.py +720 -0
- views/auth.py +626 -0
- views/facade.py +93 -0
- views/faq.py +17 -0
- views/file.py +82 -0
- views/message.py +77 -0
- views/privacy.py +8 -0
- views/subscription.py +130 -0
- views/sync.py +80 -0
- views/terms.py +8 -0
- views/views.py +31 -0
controllers/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# controllers
|
controllers/__init__.py
ADDED
|
File without changes
|
controllers/auth.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Zero-knowledge authentication controller for QuantumVault.
|
|
2
|
+
|
|
3
|
+
Registration accepts cryptographic material that the browser generated
|
|
4
|
+
and stores it verbatim: the SRP salt and verifier, the user's public
|
|
5
|
+
key, the password-encrypted private key blob, and the KDF salt. The
|
|
6
|
+
server never sees the password or any private key. Login is a Secure
|
|
7
|
+
Remote Password (SRP-6a) challenge/response (see :mod:`utils.srp6a`)
|
|
8
|
+
so the password is never transmitted.
|
|
9
|
+
|
|
10
|
+
Phone verification codes, MFA codes, and recovery codes are never
|
|
11
|
+
stored in plaintext. They are hashed with a server-side pepper via
|
|
12
|
+
:func:`utils.security.hash_secret` so a database leak does not hand
|
|
13
|
+
the attacker ready-to-use codes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import pytz
|
|
22
|
+
from flask import current_app, flash
|
|
23
|
+
from flask_mail import Mail
|
|
24
|
+
|
|
25
|
+
import clicksend_client
|
|
26
|
+
from clicksend_client import SmsMessage
|
|
27
|
+
from clicksend_client.rest import ApiException
|
|
28
|
+
|
|
29
|
+
from models.user import UserModel, UserDB
|
|
30
|
+
from models.plans import PlanDB
|
|
31
|
+
from utils import srp6a
|
|
32
|
+
from utils.mailer import external_url, mail_is_configured, send_transactional_email
|
|
33
|
+
from utils.utils import database_path
|
|
34
|
+
from utils.security import (
|
|
35
|
+
audit_event,
|
|
36
|
+
constant_time_compare,
|
|
37
|
+
hash_secret,
|
|
38
|
+
new_one_time_code,
|
|
39
|
+
verify_secret,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
DISALLOWED_USERNAME_CHARS = [';', '|', '&', '$', '`', '\'', '"', '\\', '<', '>']
|
|
43
|
+
|
|
44
|
+
configuration = clicksend_client.Configuration()
|
|
45
|
+
configuration.username = os.environ.get('CLICKSEND_USERNAME', '')
|
|
46
|
+
configuration.password = os.environ.get('CLICKSEND_API_KEY', '')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now_utc() -> datetime:
|
|
50
|
+
"""Return the current time as a timezone-aware UTC datetime.
|
|
51
|
+
|
|
52
|
+
Avoids the deprecated ``datetime.utcnow()`` which returns a naive
|
|
53
|
+
value and triggers a DeprecationWarning in Python 3.12+.
|
|
54
|
+
"""
|
|
55
|
+
return datetime.now(timezone.utc)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthController:
|
|
59
|
+
"""Handles zero-knowledge registration and SRP-6a authentication."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, db_path: str, mail: Mail, storage_uri: str):
|
|
62
|
+
"""Initialize the controller.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
db_path: Path to the SQLite user database.
|
|
66
|
+
mail: Configured Flask-Mail instance for transactional email.
|
|
67
|
+
storage_uri: Redis URI backing the ephemeral SRP session store.
|
|
68
|
+
"""
|
|
69
|
+
self.user_db = UserDB(db_path)
|
|
70
|
+
self.mail = mail
|
|
71
|
+
self.srp_store = srp6a.SRPSessionStore(storage_uri)
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Registration
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
def register(self, username: str, srp_salt: str, srp_verifier: str, public_key: str,
|
|
77
|
+
encrypted_private_key: str, kdf_salt: str, email: str, phone: str,
|
|
78
|
+
first_name: str, last_name: str, recovery_salt: Optional[str] = None,
|
|
79
|
+
encrypted_private_key_recovery: Optional[str] = None) -> bool:
|
|
80
|
+
"""Register a user from client-generated zero-knowledge credentials.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
username: The desired unique username.
|
|
84
|
+
srp_salt: SRP salt (hex) generated on the client.
|
|
85
|
+
srp_verifier: SRP verifier (hex) generated on the client.
|
|
86
|
+
public_key: The user's hybrid public key blob (opaque).
|
|
87
|
+
encrypted_private_key: The password-encrypted private key blob (opaque).
|
|
88
|
+
kdf_salt: Salt (hex) for the client-side key derivation function.
|
|
89
|
+
email: The user's email address.
|
|
90
|
+
phone: The user's phone number.
|
|
91
|
+
first_name: The user's first name.
|
|
92
|
+
last_name: The user's last name.
|
|
93
|
+
recovery_salt: Optional QV-RECOVERY-1 PBKDF2 salt (hex), generated on the client.
|
|
94
|
+
encrypted_private_key_recovery: Optional QV-RECOVERY-1 AES-256-GCM
|
|
95
|
+
wrapping of the same private key blob, keyed by a
|
|
96
|
+
client-generated recovery code instead of the password.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True on success, False if validation fails or persistence errors.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
if any(char in username for char in DISALLOWED_USERNAME_CHARS):
|
|
103
|
+
flash('The username contains disallowed characters.')
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
required = (username, srp_salt, srp_verifier, public_key, encrypted_private_key, kdf_salt)
|
|
107
|
+
if not all(required):
|
|
108
|
+
flash('Missing required cryptographic material.')
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
confirmation_token = secrets.token_urlsafe(32)
|
|
112
|
+
phone_verification_code = new_one_time_code(6)
|
|
113
|
+
phone_code_expires = _now_utc() + timedelta(minutes=30)
|
|
114
|
+
|
|
115
|
+
plan_db = PlanDB(database_path())
|
|
116
|
+
plan = plan_db.get_plan("free")
|
|
117
|
+
trial_start = _now_utc()
|
|
118
|
+
trial_end = trial_start + timedelta(days=plan["trial_days"])
|
|
119
|
+
|
|
120
|
+
self.user_db.create_user(
|
|
121
|
+
username=username,
|
|
122
|
+
srp_salt=srp_salt,
|
|
123
|
+
srp_verifier=srp_verifier,
|
|
124
|
+
public_key=public_key,
|
|
125
|
+
encrypted_private_key=encrypted_private_key,
|
|
126
|
+
kdf_salt=kdf_salt,
|
|
127
|
+
email=email,
|
|
128
|
+
phone=phone,
|
|
129
|
+
first_name=first_name,
|
|
130
|
+
last_name=last_name,
|
|
131
|
+
role="free",
|
|
132
|
+
storage_quota=plan["storage_quota"],
|
|
133
|
+
trial_start=trial_start,
|
|
134
|
+
trial_end=trial_end,
|
|
135
|
+
subscription_status="active",
|
|
136
|
+
email_verified=False,
|
|
137
|
+
confirmation_token=confirmation_token,
|
|
138
|
+
phone_verified=False,
|
|
139
|
+
phone_verification_code_hash=hash_secret(phone_verification_code),
|
|
140
|
+
phone_code_expires=phone_code_expires,
|
|
141
|
+
mfa_enabled=False,
|
|
142
|
+
recovery_salt=recovery_salt,
|
|
143
|
+
encrypted_private_key_recovery=encrypted_private_key_recovery,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
audit_event("register_success", username=username, email=email)
|
|
147
|
+
# Dispatch the email and SMS after the DB commit so we never
|
|
148
|
+
# advertise a link or code that the caller cannot subsequently use.
|
|
149
|
+
self.send_confirmation_email(email, username, confirmation_token)
|
|
150
|
+
self.send_sms_verification(phone, phone_verification_code, username)
|
|
151
|
+
return True
|
|
152
|
+
except Exception as e:
|
|
153
|
+
audit_event("register_failure", username=username, reason=type(e).__name__)
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def send_confirmation_email(self, email: str, username: str, token: str) -> bool:
|
|
157
|
+
"""Email the account-confirmation link to a freshly registered user.
|
|
158
|
+
|
|
159
|
+
The link targets :func:`views.auth.confirm_email`. When SMTP is not
|
|
160
|
+
configured, or the send fails, the link is logged at WARNING so an
|
|
161
|
+
operator can still verify the account from the server logs; this keeps
|
|
162
|
+
local and bare-VPS deployments usable before mail credentials exist.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
email: The recipient address.
|
|
166
|
+
username: The account username (used for the greeting).
|
|
167
|
+
token: The single-use confirmation token stored on the account.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if the mail server accepted the message, False otherwise.
|
|
171
|
+
"""
|
|
172
|
+
confirm_url = external_url(f"/confirm/{token}")
|
|
173
|
+
body = (
|
|
174
|
+
f"Hi {username},\n\n"
|
|
175
|
+
f"Confirm your QuantumVault email address by visiting:\n"
|
|
176
|
+
f"{confirm_url}\n\n"
|
|
177
|
+
f"If you did not create this account, you can ignore this message."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not mail_is_configured():
|
|
181
|
+
audit_event("confirmation_email_unconfigured", username=username)
|
|
182
|
+
current_app.logger.warning(
|
|
183
|
+
"Mail is not configured; confirmation link for %s: %s",
|
|
184
|
+
username,
|
|
185
|
+
confirm_url,
|
|
186
|
+
)
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
sent = send_transactional_email(
|
|
190
|
+
"Confirm your QuantumVault account", [email], body
|
|
191
|
+
)
|
|
192
|
+
audit_event("confirmation_email", username=username, sent=sent)
|
|
193
|
+
if not sent:
|
|
194
|
+
current_app.logger.warning(
|
|
195
|
+
"Confirmation email failed; link for %s: %s", username, confirm_url
|
|
196
|
+
)
|
|
197
|
+
return sent
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# SRP login
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
def srp_hello(self, username: str, client_a_hex: str) -> Optional[Tuple[str, str]]:
|
|
203
|
+
"""Begin an SRP-6a login and return the salt and server challenge B."""
|
|
204
|
+
user_data = self.user_db.get_user(username)
|
|
205
|
+
if not user_data or not user_data.get('srp_salt') or not user_data.get('srp_verifier'):
|
|
206
|
+
audit_event("srp_hello_unknown_user", username=username)
|
|
207
|
+
return None
|
|
208
|
+
server_b_hex = srp6a.hello(
|
|
209
|
+
self.srp_store,
|
|
210
|
+
username=username,
|
|
211
|
+
client_a_hex=client_a_hex,
|
|
212
|
+
salt_hex=user_data['srp_salt'],
|
|
213
|
+
verifier_hex=user_data['srp_verifier'],
|
|
214
|
+
)
|
|
215
|
+
if server_b_hex is None:
|
|
216
|
+
audit_event("srp_hello_invalid_A", username=username)
|
|
217
|
+
return None
|
|
218
|
+
audit_event("srp_hello_success", username=username)
|
|
219
|
+
return user_data['srp_salt'], server_b_hex
|
|
220
|
+
|
|
221
|
+
def srp_verify(self, username: str, client_m1_hex: str) -> Optional[Tuple[UserModel, str]]:
|
|
222
|
+
"""Complete an SRP-6a login and return the authenticated user and proof."""
|
|
223
|
+
server_m2_hex = srp6a.verify(self.srp_store, username, client_m1_hex)
|
|
224
|
+
if server_m2_hex is None:
|
|
225
|
+
audit_event("srp_verify_failure", username=username)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
user_data = self.user_db.get_user(username)
|
|
229
|
+
if not user_data:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
audit_event("srp_verify_success", username=username)
|
|
233
|
+
user = UserModel(
|
|
234
|
+
id=user_data["id"],
|
|
235
|
+
username=user_data["username"],
|
|
236
|
+
role=user_data["role"],
|
|
237
|
+
email=user_data["email"],
|
|
238
|
+
phone=user_data["phone"],
|
|
239
|
+
first_name=user_data["first_name"],
|
|
240
|
+
last_name=user_data["last_name"],
|
|
241
|
+
storage_quota=user_data["storage_quota"],
|
|
242
|
+
trial_start=user_data["trial_start"],
|
|
243
|
+
trial_end=user_data["trial_end"],
|
|
244
|
+
subscription_status=user_data["subscription_status"],
|
|
245
|
+
email_verified=user_data["email_verified"],
|
|
246
|
+
mfa_enabled=user_data["mfa_enabled"]
|
|
247
|
+
)
|
|
248
|
+
return user, server_m2_hex
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# SMS provider
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
def send_sms_verification(self, phone: str, code: str, username: str = "") -> bool:
|
|
254
|
+
"""Send a verification code via SMS."""
|
|
255
|
+
if not configuration.username or not configuration.password:
|
|
256
|
+
return False
|
|
257
|
+
try:
|
|
258
|
+
api_instance = clicksend_client.SMSApi(clicksend_client.ApiClient(configuration))
|
|
259
|
+
message = f"Your QuantumVault verification code is: {code}"
|
|
260
|
+
if username:
|
|
261
|
+
message += f" https://quantumvault.pro/verify_phone?username={username}"
|
|
262
|
+
|
|
263
|
+
sms_message = SmsMessage(source="QuantumVault", body=message, to=phone)
|
|
264
|
+
sms_messages = clicksend_client.SmsMessageCollection(messages=[sms_message])
|
|
265
|
+
api_instance.sms_send_post(sms_messages)
|
|
266
|
+
return True
|
|
267
|
+
except (ApiException, Exception) as e:
|
|
268
|
+
audit_event("sms_send_failure", reason=str(e))
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# Phone verification
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
def verify_phone_code(self, username: str, code: str) -> bool:
|
|
275
|
+
"""Verify a phone verification code for a user.
|
|
276
|
+
|
|
277
|
+
The stored value is the peppered hash; the supplied code is
|
|
278
|
+
hashed in the same way and the two digests are compared in
|
|
279
|
+
constant time.
|
|
280
|
+
"""
|
|
281
|
+
user_data = self.user_db.get_user(username)
|
|
282
|
+
if not user_data:
|
|
283
|
+
audit_event("phone_verify_unknown_user", username=username)
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
stored_hash = user_data.get("phone_verification_code_hash") or user_data.get("phone_verification_code")
|
|
287
|
+
expires_at = user_data.get("phone_code_expires")
|
|
288
|
+
|
|
289
|
+
if not verify_secret(code, stored_hash):
|
|
290
|
+
audit_event("phone_verify_bad_code", username=username)
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
if not self._is_code_valid(expires_at):
|
|
294
|
+
audit_event("phone_verify_expired", username=username)
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
self.user_db.update_user_phone_status(
|
|
299
|
+
username=username,
|
|
300
|
+
phone_verified=True,
|
|
301
|
+
phone_verification_code_hash=None,
|
|
302
|
+
phone_code_expires=None
|
|
303
|
+
)
|
|
304
|
+
audit_event("phone_verify_success", username=username)
|
|
305
|
+
return True
|
|
306
|
+
except Exception as e:
|
|
307
|
+
audit_event("phone_verify_db_error", username=username, reason=type(e).__name__)
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
def resend_phone_code(self, username: str) -> bool:
|
|
311
|
+
"""Issue and send a fresh phone verification code.
|
|
312
|
+
|
|
313
|
+
Generates a new one-time code, stores its peppered hash with a
|
|
314
|
+
30-minute expiry (overwriting any previous pending code), and
|
|
315
|
+
dispatches it by SMS. Returns False when the account is unknown,
|
|
316
|
+
has no phone number, is already verified, or the SMS provider is
|
|
317
|
+
not configured, so the caller can surface an honest result.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
username: The account requesting a new code.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
True if a new code was generated, stored, and accepted by the
|
|
324
|
+
SMS provider; False otherwise.
|
|
325
|
+
"""
|
|
326
|
+
user_data = self.user_db.get_user(username)
|
|
327
|
+
if not user_data or not user_data.get("phone"):
|
|
328
|
+
audit_event("phone_resend_unknown_user", username=username)
|
|
329
|
+
return False
|
|
330
|
+
if user_data.get("phone_verified"):
|
|
331
|
+
audit_event("phone_resend_already_verified", username=username)
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
code = new_one_time_code(6)
|
|
335
|
+
expires_at = _now_utc() + timedelta(minutes=30)
|
|
336
|
+
try:
|
|
337
|
+
self.user_db.update_user_phone_status(
|
|
338
|
+
username=username,
|
|
339
|
+
phone_verification_code_hash=hash_secret(code),
|
|
340
|
+
phone_code_expires=expires_at,
|
|
341
|
+
)
|
|
342
|
+
sent = self.send_sms_verification(user_data["phone"], code, username)
|
|
343
|
+
audit_event("phone_resend", username=username, sent=sent)
|
|
344
|
+
return sent
|
|
345
|
+
except Exception as e:
|
|
346
|
+
audit_event("phone_resend_error", username=username, reason=type(e).__name__)
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _is_code_valid(self, expires_at) -> bool:
|
|
350
|
+
"""Return True if a verification code has not yet expired."""
|
|
351
|
+
if not expires_at:
|
|
352
|
+
return False
|
|
353
|
+
try:
|
|
354
|
+
if isinstance(expires_at, str):
|
|
355
|
+
expires_dt = datetime.fromisoformat(expires_at)
|
|
356
|
+
else:
|
|
357
|
+
expires_dt = expires_at
|
|
358
|
+
|
|
359
|
+
if expires_dt.tzinfo is None:
|
|
360
|
+
expires_dt = pytz.utc.localize(expires_dt)
|
|
361
|
+
|
|
362
|
+
return _now_utc() <= expires_dt
|
|
363
|
+
except (ValueError, TypeError):
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# MFA
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
def verify_mfa_code(self, username: str, code: str) -> bool:
|
|
370
|
+
"""Verify a multi-factor authentication code for a user."""
|
|
371
|
+
user_data = self.user_db.get_user(username)
|
|
372
|
+
if not user_data:
|
|
373
|
+
audit_event("mfa_unknown_user", username=username)
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
stored_hash = user_data.get("mfa_code_hash") or user_data.get("mfa_code")
|
|
377
|
+
if verify_secret(code, stored_hash) and self._is_code_valid(user_data.get("mfa_code_expires")):
|
|
378
|
+
try:
|
|
379
|
+
self.user_db.update_user_mfa_status(
|
|
380
|
+
username=username,
|
|
381
|
+
mfa_code_hash=None,
|
|
382
|
+
mfa_code_expires=None
|
|
383
|
+
)
|
|
384
|
+
audit_event("mfa_success", username=username)
|
|
385
|
+
return True
|
|
386
|
+
except Exception as e:
|
|
387
|
+
audit_event("mfa_db_error", username=username, reason=type(e).__name__)
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
audit_event("mfa_failure", username=username)
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
def send_mfa_code(self, username: str) -> bool:
|
|
394
|
+
"""Generate, store, and send an MFA code to the user's phone."""
|
|
395
|
+
user_data = self.user_db.get_user(username)
|
|
396
|
+
if not user_data or not user_data.get("phone_verified"):
|
|
397
|
+
audit_event("mfa_send_unverified", username=username)
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
mfa_code = new_one_time_code(6)
|
|
401
|
+
expires_at = _now_utc() + timedelta(minutes=10)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
self.user_db.update_user_mfa_status(
|
|
405
|
+
username=username,
|
|
406
|
+
mfa_code_hash=hash_secret(mfa_code),
|
|
407
|
+
mfa_code_expires=expires_at
|
|
408
|
+
)
|
|
409
|
+
ok = self.send_sms_verification(user_data["phone"], mfa_code)
|
|
410
|
+
audit_event("mfa_send", username=username, sent=ok)
|
|
411
|
+
return ok
|
|
412
|
+
except Exception as e:
|
|
413
|
+
audit_event("mfa_send_error", username=username, reason=type(e).__name__)
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
def toggle_mfa(self, username: str, enable: bool) -> None:
|
|
417
|
+
"""Enable or disable MFA for a user."""
|
|
418
|
+
try:
|
|
419
|
+
self.user_db.update_user_mfa_status(
|
|
420
|
+
username=username,
|
|
421
|
+
mfa_enabled=enable
|
|
422
|
+
)
|
|
423
|
+
audit_event("mfa_toggle", username=username, enabled=enable)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
audit_event("mfa_toggle_error", username=username, reason=type(e).__name__)
|
|
426
|
+
raise
|
controllers/contact.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from models.contact import ContactDB, ContactModel
|
|
2
|
+
from flask import flash
|
|
3
|
+
|
|
4
|
+
class ContactController:
|
|
5
|
+
"""Handles logic related to contact messages."""
|
|
6
|
+
def __init__(self, db_path: str):
|
|
7
|
+
"""Initialize the ContactController with the database path.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
db_path (str): Path to the SQLite database file.
|
|
11
|
+
"""
|
|
12
|
+
self.contact_db = ContactDB(db_path)
|
|
13
|
+
|
|
14
|
+
def create_contact(self, user_id: int, subject: str, message: str) -> bool:
|
|
15
|
+
"""Create a new contact message.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
user_id (int): ID of the user sending the message.
|
|
19
|
+
subject (str): Subject of the message.
|
|
20
|
+
message (str): Content of the message.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
bool: True if the message was created successfully, False otherwise.
|
|
24
|
+
"""
|
|
25
|
+
if not subject or not message:
|
|
26
|
+
flash("Subject and message are required.")
|
|
27
|
+
return False
|
|
28
|
+
if len(subject) > 100:
|
|
29
|
+
flash("Subject must not exceed 100 characters.")
|
|
30
|
+
return False
|
|
31
|
+
if len(message) > 1000:
|
|
32
|
+
flash("Message must not exceed 1000 characters.")
|
|
33
|
+
return False
|
|
34
|
+
return self.contact_db.create_contact(user_id, subject, message)
|
|
35
|
+
|
|
36
|
+
def get_user_contacts(self, user_id: int) -> list[ContactModel]:
|
|
37
|
+
"""Retrieve all contact messages for a user.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
user_id (int): ID of the user.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
list[ContactModel]: List of contact messages.
|
|
44
|
+
"""
|
|
45
|
+
contacts = self.contact_db.get_user_contacts(user_id)
|
|
46
|
+
return [ContactModel(**contact) for contact in contacts]
|