Skip to content

Commit 361c426

Browse files
authored
feat: User IAM audit events (M2-10744) (#2074)
🔗 [Jira Ticket M2-10744](https://mindlogger.atlassian.net/browse/M2-10744) Changes include: - `user:create` events - `user:delete` events - `user:password:change` events - `user:password:recovery:initiate` events - `user:password:recovery:approve` events - `user:mfa:enable` events - `user:mfa:disable` (on verify) events - `user:mfa:disable` (on confirm) events - `user:mfa:recovery:view` events - `user:mfa:recovery:download` events - `user:mfa:recovery:use` (on user login) events - `user:mfa:recovery:use` (on MFA disable) events - `user:mfa:recovery:use` (on MFA view) events
1 parent cc80e3e commit 361c426

12 files changed

Lines changed: 1159 additions & 486 deletions

src/apps/authentication/api/auth.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,16 @@ async def verify_mfa_recovery_code(
399399
request_info=request_metadata,
400400
)
401401

402-
except RecoveryCodeNotFoundError:
402+
except RecoveryCodeNotFoundError as e:
403+
await log(
404+
AuditEvent(
405+
event_action=EventAction.USER_MFA_RECOVERY_USE,
406+
user_id=user_id,
407+
user_target_id=user_id,
408+
**http_audit_fields(request, e),
409+
)
410+
)
411+
403412
# No unused codes exist - increment both counters
404413
session_count = await mfa_service.increment_failed_totp_attempts(mfa_session_id)
405414
global_count = await mfa_service.increment_global_failed_attempts(user_id)
@@ -456,7 +465,16 @@ async def verify_mfa_recovery_code(
456465

457466
raise
458467

459-
except RecoveryCodeInvalidError:
468+
except RecoveryCodeInvalidError as e:
469+
await log(
470+
AuditEvent(
471+
event_action=EventAction.USER_MFA_RECOVERY_USE,
472+
user_id=user_id,
473+
user_target_id=user_id,
474+
**http_audit_fields(request, e),
475+
)
476+
)
477+
460478
# Invalid code - increment both per-session and global counters
461479
session_count = await mfa_service.increment_failed_totp_attempts(mfa_session_id)
462480
global_count = await mfa_service.increment_global_failed_attempts(user_id)
@@ -569,9 +587,14 @@ async def verify_mfa_recovery_code(
569587
raise
570588

571589
# Step 7: Return response
572-
token = Token(access_token=access_token, refresh_token=refresh_token)
573-
public_user = PublicUser.from_user(user)
574-
590+
await log(
591+
AuditEvent(
592+
event_action=EventAction.USER_MFA_RECOVERY_USE,
593+
user_id=user.id,
594+
user_target_id=user.id,
595+
**http_audit_fields(request),
596+
)
597+
)
575598
await log(
576599
AuditEvent(
577600
event_action=EventAction.USER_SESSION_LOGIN,
@@ -580,6 +603,9 @@ async def verify_mfa_recovery_code(
580603
)
581604
)
582605

606+
token = Token(access_token=access_token, refresh_token=refresh_token)
607+
public_user = PublicUser.from_user(user)
608+
583609
return Response(
584610
result=UserLogin(
585611
token=token,

src/apps/authentication/services/recovery_codes.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
__all__ = [
3131
"generate_random_code",
3232
"format_recovery_code",
33+
"is_recovery_code",
3334
"hash_recovery_code",
3435
"verify_recovery_code",
3536
"encrypt_recovery_code",
@@ -42,20 +43,23 @@
4243
]
4344

4445

46+
# Alphanumeric recovery codes (uppercase letters and digits)
47+
ALPHABET = string.ascii_uppercase + string.digits
48+
49+
# Get length from config (10 alphanumeric characters)
50+
LENGTH = settings.mfa.recovery_code_length
51+
MID = LENGTH // 2
52+
53+
4554
def generate_random_code() -> str:
4655
"""
4756
Generate a random recovery code.
4857
4958
Returns:
5059
str: Random alphanumeric code in format XXXXX-XXXXX (e.g., "A3F7K-9B2Q5")
5160
"""
52-
# Get length from config (10 alphanumeric characters)
53-
length = settings.mfa.recovery_code_length
54-
55-
# Generate random alphanumeric string (uppercase letters and digits)
5661
# Using secrets for cryptographically strong random generation
57-
alphabet = string.ascii_uppercase + string.digits
58-
random_chars = "".join(secrets.choice(alphabet) for _ in range(length))
62+
random_chars = "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
5963

6064
# Format as XXXXX-XXXXX
6165
return format_recovery_code(random_chars)
@@ -71,12 +75,25 @@ def format_recovery_code(code: str) -> str:
7175
Returns:
7276
str: Formatted code with hyphen (e.g., "A3F7K-9B2Q5")
7377
"""
74-
if len(code) != settings.mfa.recovery_code_length:
75-
raise ValueError(f"Code must be {settings.mfa.recovery_code_length} characters")
78+
if len(code) != LENGTH:
79+
raise ValueError(f"Code must be {LENGTH} characters")
7680

7781
# Split in middle and add hyphen
78-
mid = len(code) // 2
79-
return f"{code[:mid]}-{code[mid:]}"
82+
return f"{code[:MID]}-{code[MID:]}"
83+
84+
85+
def is_recovery_code(code: str) -> bool:
86+
"""
87+
Check if string has the shape of a recovery code (XXXXX-XXXXX).
88+
89+
Args:
90+
code: Recovery code to check (e.g., "A3F7K-9B2Q5")
91+
92+
Returns:
93+
bool: True if code has the right shape
94+
"""
95+
left, _, right = code.partition("-")
96+
return len(left) == len(right) == MID and not {*left, *right}.difference(ALPHABET)
8097

8198

8299
def hash_recovery_code(code: str) -> str:

src/apps/authentication/tests/test_recovery_code_verification.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ async def test_valid_recovery_code_returns_tokens(
107107
mfa_token = login_response.json()["result"]["mfaToken"]
108108

109109
# Step 2: Verify with valid recovery code
110-
valid_code = codes[0]
111110
audit_log = mocker.patch("apps.authentication.api.auth.log")
111+
valid_code = codes[0]
112112
response = await client.post(
113113
url=self.verify_recovery_url,
114114
data=dict(
@@ -118,8 +118,15 @@ async def test_valid_recovery_code_returns_tokens(
118118
),
119119
)
120120

121-
audit_log.assert_awaited_once()
122-
event = audit_log.call_args[0][0]
121+
assert audit_log.await_count == 2
122+
123+
event = audit_log.call_args_list[0].args[0]
124+
assert event.user_id == user.id
125+
assert event.user_target_id == user.id
126+
assert event.event_action == EventAction.USER_MFA_RECOVERY_USE
127+
assert event.event_outcome == EventOutcome.SUCCESS
128+
129+
event = audit_log.call_args_list[1].args[0]
123130
assert event.user_id == user.id
124131
assert event.event_action == EventAction.USER_SESSION_LOGIN
125132
assert event.event_outcome == EventOutcome.SUCCESS
@@ -168,8 +175,15 @@ async def test_invalid_recovery_code_returns_error(
168175
),
169176
)
170177

171-
audit_log.assert_awaited_once()
172-
event = audit_log.call_args[0][0]
178+
assert audit_log.await_count == 2
179+
180+
event = audit_log.call_args_list[0].args[0]
181+
assert event.user_id == user.id
182+
assert event.user_target_id == user.id
183+
assert event.event_action == EventAction.USER_MFA_RECOVERY_USE
184+
assert event.event_outcome == EventOutcome.FAILURE
185+
186+
event = audit_log.call_args_list[1].args[0]
173187
assert event.user_id == user.id
174188
assert event.event_action == EventAction.USER_SESSION_LOGIN
175189
assert event.event_outcome == EventOutcome.FAILURE
@@ -378,7 +392,9 @@ async def test_replay_protection_logs_security_warning(
378392
warning_messages = [record.message for record in caplog.records if record.levelname == "WARNING"]
379393
assert any("Replay attack detected" in msg for msg in warning_messages)
380394

381-
async def test_no_recovery_codes_returns_not_found(self, client: TestClient, user_with_mfa_no_codes: User):
395+
async def test_no_recovery_codes_returns_not_found(
396+
self, client: TestClient, user_with_mfa_no_codes: User, mocker: MockerFixture
397+
):
382398
"""Test that verification fails with 404 when user has no recovery codes."""
383399
user = user_with_mfa_no_codes
384400

@@ -395,6 +411,7 @@ async def test_no_recovery_codes_returns_not_found(self, client: TestClient, use
395411
mfa_token = login_response.json()["result"]["mfaToken"]
396412

397413
# Try to verify with any code
414+
audit_log = mocker.patch("apps.authentication.api.auth.log")
398415
response = await client.post(
399416
url=self.verify_recovery_url,
400417
data=dict(
@@ -404,6 +421,19 @@ async def test_no_recovery_codes_returns_not_found(self, client: TestClient, use
404421
),
405422
)
406423

424+
assert audit_log.await_count == 2
425+
426+
event = audit_log.call_args_list[0].args[0]
427+
assert event.user_id == user.id
428+
assert event.user_target_id == user.id
429+
assert event.event_action == EventAction.USER_MFA_RECOVERY_USE
430+
assert event.event_outcome == EventOutcome.FAILURE
431+
432+
event = audit_log.call_args_list[1].args[0]
433+
assert event.user_id == user.id
434+
assert event.event_action == EventAction.USER_SESSION_LOGIN
435+
assert event.event_outcome == EventOutcome.FAILURE
436+
407437
# Assert: Not found error (404)
408438
assert response.status_code == http.HTTPStatus.NOT_FOUND
409439

src/apps/users/api/password.py

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
from fastapi import Body, Depends, Query, Request
55
from starlette import status
66

7+
from apps.audit import AuditEvent, EventAction, http_audit_fields, log
78
from apps.authentication.deps import get_current_user
89
from apps.authentication.services import AuthenticationService
910
from apps.job.service import JobService
1011
from apps.shared.domain.response import Response
12+
from apps.shared.exception import BaseError
1113
from apps.shared.response import EmptyResponse
1214
from apps.users.cruds.user import UsersCRUD
1315
from apps.users.domain import (
@@ -30,28 +32,49 @@
3032

3133

3234
async def password_update(
35+
request: Request,
3336
user: User = Depends(get_current_user),
3437
schema: ChangePasswordRequest = Body(...),
3538
session=Depends(get_session),
3639
) -> Response[PublicUser]:
3740
"""General endpoint for update password for signin."""
38-
reencryption_in_progress = await JobService(session, user.id).is_job_in_progress("reencrypt_answers")
39-
if reencryption_in_progress:
40-
raise ReencryptionInProgressError()
41-
42-
async with atomic(session):
43-
AuthenticationService.verify_password(
44-
schema.prev_password,
45-
user.hashed_password,
41+
try:
42+
reencryption_in_progress = await JobService(session, user.id).is_job_in_progress("reencrypt_answers")
43+
if reencryption_in_progress:
44+
raise ReencryptionInProgressError()
45+
46+
async with atomic(session):
47+
AuthenticationService.verify_password(
48+
schema.prev_password,
49+
user.hashed_password,
50+
)
51+
52+
password_hash: str = AuthenticationService.get_password_hash(schema.password)
53+
password = UserChangePassword(hashed_password=password_hash)
54+
55+
updated_user: User = await UsersCRUD(session).change_password(user, password)
56+
57+
# Create public representation of the internal user
58+
public_user = PublicUser.from_user(updated_user)
59+
except BaseError as e:
60+
await log(
61+
AuditEvent(
62+
user_id=user.id,
63+
user_target_id=user.id,
64+
event_action=EventAction.USER_PASSWORD_CHANGE,
65+
**http_audit_fields(request, e),
66+
)
4667
)
47-
48-
password_hash: str = AuthenticationService.get_password_hash(schema.password)
49-
password = UserChangePassword(hashed_password=password_hash)
50-
51-
updated_user: User = await UsersCRUD(session).change_password(user, password)
52-
53-
# Create public representation of the internal user
54-
public_user = PublicUser.from_user(updated_user)
68+
raise
69+
70+
await log(
71+
AuditEvent(
72+
user_id=user.id,
73+
user_target_id=user.id,
74+
event_action=EventAction.USER_PASSWORD_CHANGE,
75+
**http_audit_fields(request),
76+
)
77+
)
5578

5679
email = user.email_encrypted
5780
retries = settings.task_answer_encryption.max_retries
@@ -69,27 +92,80 @@ async def password_recovery(
6992
"""General endpoint for sending password recovery email
7093
and stored info in Redis.
7194
"""
72-
# Send the password recovery the internal password recovery service
73-
async with atomic(session):
74-
try:
75-
content_source = await get_mindlogger_content_source(request)
76-
await PasswordRecoveryService(session).send_password_recovery(schema, content_source, language)
77-
except UserNotFound:
78-
pass # mute error in terms of user enumeration vulnerability
95+
user_id: uuid.UUID | None = None
96+
try:
97+
async with atomic(session):
98+
try:
99+
content_source = await get_mindlogger_content_source(request)
100+
public_user = await PasswordRecoveryService(session).send_password_recovery(
101+
schema, content_source, language
102+
)
103+
user_id = public_user.id
104+
except UserNotFound as e:
105+
# mute error in terms of user enumeration vulnerability
106+
await log(
107+
AuditEvent(
108+
user_id=None,
109+
user_email=schema.email,
110+
event_action=EventAction.USER_PASSWORD_RECOVERY_INITIATE,
111+
**http_audit_fields(request, e),
112+
)
113+
)
114+
return EmptyResponse(status_code=status.HTTP_201_CREATED)
115+
except BaseError as e:
116+
await log(
117+
AuditEvent(
118+
user_id=None,
119+
user_email=schema.email,
120+
event_action=EventAction.USER_PASSWORD_RECOVERY_INITIATE,
121+
**http_audit_fields(request, e),
122+
)
123+
)
124+
raise
125+
126+
await log(
127+
AuditEvent(
128+
user_id=user_id,
129+
user_target_id=user_id,
130+
event_action=EventAction.USER_PASSWORD_RECOVERY_INITIATE,
131+
**http_audit_fields(request),
132+
)
133+
)
79134

80135
return EmptyResponse(status_code=status.HTTP_201_CREATED)
81136

82137

83138
async def password_recovery_approve(
139+
request: Request,
84140
schema: PasswordRecoveryApproveRequest = Body(...),
85141
session=Depends(get_session),
86142
) -> Response[PublicUser]:
87143
"""General endpoint to approve the password recovery."""
88144

89145
# Approve the password recovery
90146
# NOTE: also check if the data exists and tokens are not expired
91-
async with atomic(session):
92-
public_user: PublicUser = await PasswordRecoveryService(session).approve(schema)
147+
try:
148+
async with atomic(session):
149+
public_user: PublicUser = await PasswordRecoveryService(session).approve(schema)
150+
except BaseError as e:
151+
await log(
152+
AuditEvent(
153+
user_id=None,
154+
user_email=schema.email,
155+
event_action=EventAction.USER_PASSWORD_RECOVERY_APPROVE,
156+
**http_audit_fields(request, e),
157+
)
158+
)
159+
raise
160+
161+
await log(
162+
AuditEvent(
163+
user_id=public_user.id,
164+
user_target_id=public_user.id,
165+
event_action=EventAction.USER_PASSWORD_RECOVERY_APPROVE,
166+
**http_audit_fields(request),
167+
)
168+
)
93169

94170
return Response[PublicUser](result=public_user)
95171

0 commit comments

Comments
 (0)