Skip to content

Commit dd76e3c

Browse files
committed
- Updated email handler to not mutate user object since we cannot deduce what user profile to update based on the given event data.
1 parent f83aff5 commit dd76e3c

6 files changed

Lines changed: 279 additions & 71 deletions

File tree

tdrs-backend/tdpservice/security/event_handler.py

Lines changed: 82 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
from datetime import datetime
55
from datetime import timezone as dt_timezone
66

7-
from django.core.exceptions import ValidationError
8-
from django.core.validators import validate_email
97
from django.utils import timezone
108

119
from tdpservice.security.models import SecurityEventToken, SecurityEventType
@@ -65,34 +63,17 @@ def _get_emails(security_event):
6563
new_email = event_data.get("new-value")
6664
return new_email, old_email
6765

68-
def _is_valid_email(email):
69-
"""Return whether the value is formatted as an email address."""
70-
try:
71-
validate_email(email)
72-
except ValidationError:
73-
return False
74-
return True
75-
7666
def _handle_email_changed(security_event):
77-
"""Handle email-changed event."""
67+
"""Handle email-changed event without mutating the user record."""
7868
new_email, old_email = SecurityEventHandler._get_emails(security_event)
7969
user = security_event.user
80-
81-
if not new_email:
82-
logger.info(
83-
f"User {user.username} identifier changed for email {old_email}; no new email provided"
84-
)
85-
return
86-
87-
if not SecurityEventHandler._is_valid_email(new_email):
88-
raise ValueError(
89-
f"Invalid new email value in identifier-changed event: {new_email}"
90-
)
91-
92-
user.email = new_email
93-
user.username = new_email
94-
user.save()
95-
logger.info(f"User changed email from {old_email} to {new_email}")
70+
logger.info(
71+
"User %s identifier changed for email %s; SET new-value=%s. "
72+
"User email is synced from OIDC claims on login.",
73+
user.username,
74+
old_email,
75+
new_email,
76+
)
9677

9778
def _handle_email_recycled(security_event):
9879
"""Handle email-recycled event."""
@@ -133,6 +114,56 @@ def _handle_reproof_complete(security_event):
133114
SecurityEventType.REPROOF_COMPLETE: _handle_reproof_complete,
134115
}
135116

117+
user_mutation_event_types = {
118+
SecurityEventType.ACCOUNT_DISABLED,
119+
SecurityEventType.ACCOUNT_ENABLED,
120+
SecurityEventType.ACCOUNT_PURGED,
121+
}
122+
123+
@classmethod
124+
def _get_issued_at(cls, decoded_jwt):
125+
"""Convert the SET issued-at timestamp to a datetime."""
126+
iat_timestamp = decoded_jwt.get("iat")
127+
if not iat_timestamp:
128+
return None
129+
130+
try:
131+
return datetime.fromtimestamp(iat_timestamp, tz=dt_timezone.utc)
132+
except (ValueError, TypeError) as e:
133+
logger.warning(f"Error converting timestamp {iat_timestamp}: {e}")
134+
return None
135+
136+
@classmethod
137+
def _create_security_event(cls, user, event_type, event_data, decoded_jwt):
138+
"""Create a SecurityEventToken for a known or unmatched event subject."""
139+
subject = event_data.get("subject", {})
140+
return SecurityEventToken.objects.create(
141+
user=user,
142+
email=user.email if user else subject.get("email"),
143+
event_type=event_type,
144+
event_data=event_data,
145+
jwt_id=decoded_jwt.get("jti"),
146+
issuer=decoded_jwt.get("iss"),
147+
issued_at=cls._get_issued_at(decoded_jwt),
148+
)
149+
150+
@classmethod
151+
def _mark_processed(cls, security_event):
152+
"""Mark a security event as processed."""
153+
security_event.processed = True
154+
security_event.processed_at = timezone.now()
155+
security_event.save()
156+
157+
@classmethod
158+
def _has_subject_identifier(cls, subject):
159+
"""Return whether the event subject contains an identifier we understand."""
160+
return bool(subject.get("sub") or subject.get("email"))
161+
162+
@classmethod
163+
def _event_mutates_user(cls, event_type):
164+
"""Return whether handling the event is allowed to update the user model."""
165+
return event_type in cls.user_mutation_event_types
166+
136167
@classmethod
137168
def _get_user(cls, subject):
138169
"""Get User model from email or UUID."""
@@ -165,36 +196,36 @@ def handle_event(cls, event_type, event_data, decoded_jwt):
165196
"""Handle specific event types."""
166197
try:
167198
subject = event_data.get("subject", {})
168-
user = cls._get_user(subject)
169-
170-
# Convert Unix timestamp to datetime if present
171-
iat_timestamp = decoded_jwt.get("iat")
172-
issued_at = None
173-
if iat_timestamp:
174-
try:
175-
issued_at = datetime.fromtimestamp(
176-
iat_timestamp, tz=dt_timezone.utc
177-
)
178-
except (ValueError, TypeError) as e:
179-
logger.warning(f"Error converting timestamp {iat_timestamp}: {e}")
180-
181-
security_event = SecurityEventToken.objects.create(
182-
user=user,
183-
email=user.email,
184-
event_type=event_type,
185-
event_data=event_data,
186-
jwt_id=decoded_jwt.get("jti"),
187-
issuer=decoded_jwt.get("iss"),
188-
issued_at=issued_at,
199+
try:
200+
user = cls._get_user(subject)
201+
except ValueError:
202+
if cls._event_mutates_user(
203+
event_type
204+
) or not cls._has_subject_identifier(subject):
205+
raise
206+
207+
security_event = cls._create_security_event(
208+
None, event_type, event_data, decoded_jwt
209+
)
210+
cls._mark_processed(security_event)
211+
logger.warning(
212+
"Recorded %s event with unmatched subject %s. "
213+
"No user mutation was applied; email is synced from OIDC "
214+
"claims on login.",
215+
event_type,
216+
subject,
217+
)
218+
return
219+
220+
security_event = cls._create_security_event(
221+
user, event_type, event_data, decoded_jwt
189222
)
190223

191224
# Call the appropriate handler
192225
handler = cls.handler_map.get(event_type, cls._handle_unknown_event)
193226
handler(security_event)
194227

195-
security_event.processed = True
196-
security_event.processed_at = timezone.now()
197-
security_event.save()
228+
cls._mark_processed(security_event)
198229

199230
except Exception as e:
200231
logger.exception(f"Error handling event {event_type}: {e}")

tdrs-backend/tdpservice/security/test/test_event_handler.py

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,55 @@ def test_recovery_information_changed(
230230
decoded_jwt["iat"], tz=timezone.utc
231231
)
232232

233+
@pytest.mark.django_db
234+
@pytest.mark.parametrize(
235+
"event_type",
236+
[
237+
SecurityEventType.MFA_LOCKED,
238+
SecurityEventType.EMAIL_CHANGED,
239+
SecurityEventType.EMAIL_RECYCLED,
240+
SecurityEventType.PASSWORD_RESET,
241+
SecurityEventType.RECOVERY_ACTIVATED,
242+
SecurityEventType.RECOVERY_INFORMATION_CHANGED,
243+
SecurityEventType.REPROOF_COMPLETE,
244+
],
245+
)
246+
def test_non_mutating_events_do_not_update_user(
247+
self, stt_data_analyst, event_type, decoded_jwt
248+
):
249+
"""Test only account status and purge events mutate the user model."""
250+
prev_email = stt_data_analyst.email
251+
prev_username = stt_data_analyst.username
252+
prev_active = stt_data_analyst.is_active
253+
prev_login_gov_uuid = str(stt_data_analyst.login_gov_uuid)
254+
prev_status = stt_data_analyst.account_approval_status
255+
event_data = {"subject": {"sub": str(stt_data_analyst.login_gov_uuid)}}
256+
257+
if event_type in {
258+
SecurityEventType.EMAIL_CHANGED,
259+
SecurityEventType.EMAIL_RECYCLED,
260+
}:
261+
event_data = {
262+
"subject": {
263+
"email": stt_data_analyst.email,
264+
"subject_type": "email",
265+
}
266+
}
267+
268+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
269+
270+
stt_data_analyst.refresh_from_db()
271+
272+
assert stt_data_analyst.email == prev_email
273+
assert stt_data_analyst.username == prev_username
274+
assert stt_data_analyst.is_active == prev_active
275+
assert str(stt_data_analyst.login_gov_uuid) == prev_login_gov_uuid
276+
assert stt_data_analyst.account_approval_status == prev_status
277+
278+
token = SecurityEventToken.objects.first()
279+
assert token.processed is True
280+
assert token.event_type == event_type
281+
233282
@pytest.mark.django_db
234283
def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
235284
"""Test handling of mfa-locked event."""
@@ -262,17 +311,19 @@ def test_mfa_locked(self, stt_data_analyst, event_data, decoded_jwt):
262311
def test_email_changed(
263312
self, stt_data_analyst, email_changed_event_data, decoded_jwt
264313
):
265-
"""Test handling email-changed event updates user's email and username."""
314+
"""Test handling email-changed event does not update user's email and username."""
315+
prev_email = stt_data_analyst.email
316+
prev_username = stt_data_analyst.username
317+
266318
event_type = SecurityEventType.EMAIL_CHANGED
267319
SecurityEventHandler.handle_event(
268320
event_type, email_changed_event_data, decoded_jwt
269321
)
270322

271323
stt_data_analyst.refresh_from_db()
272324

273-
new_email = email_changed_event_data["new-value"]
274-
assert stt_data_analyst.email == new_email
275-
assert stt_data_analyst.username == new_email
325+
assert stt_data_analyst.email == prev_email
326+
assert stt_data_analyst.username == prev_username
276327

277328
assert SecurityEventToken.objects.count() == 1
278329
token = SecurityEventToken.objects.first()
@@ -311,6 +362,59 @@ def test_email_changed_without_new_value_does_not_update_user(
311362
assert token.event_type == event_type
312363
assert token.event_data == email_changed_event_data_without_new_value
313364

365+
@pytest.mark.django_db
366+
def test_email_changed_for_unknown_email_records_processed_event(
367+
self, caplog, decoded_jwt
368+
):
369+
"""Test Login.gov identifier-changed can arrive with a new unknown email."""
370+
event_type = SecurityEventType.EMAIL_CHANGED
371+
event_data = {
372+
"subject": {
373+
"email": "new_login_email@example.com",
374+
"subject_type": "email",
375+
}
376+
}
377+
378+
with caplog.at_level(logging.WARNING):
379+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
380+
381+
assert "No user found with the provided 'email'" not in caplog.text
382+
assert "unmatched subject" in caplog.text
383+
384+
assert SecurityEventToken.objects.count() == 1
385+
token = SecurityEventToken.objects.first()
386+
assert token.user is None
387+
assert token.email == "new_login_email@example.com"
388+
assert token.processed is True
389+
assert token.processed_at is not None
390+
assert token.event_type == event_type
391+
assert token.event_data == event_data
392+
assert token.jwt_id == decoded_jwt["jti"]
393+
assert token.issuer == decoded_jwt["iss"]
394+
395+
@pytest.mark.django_db
396+
def test_non_mutating_event_for_unknown_sub_records_processed_event(
397+
self, caplog, decoded_jwt
398+
):
399+
"""Test non-mutating events with an unknown sub are recorded for audit."""
400+
event_type = SecurityEventType.PASSWORD_RESET
401+
event_data = {"subject": {"sub": str(uuid.uuid4())}}
402+
403+
with caplog.at_level(logging.WARNING):
404+
SecurityEventHandler.handle_event(event_type, event_data, decoded_jwt)
405+
406+
assert "No user found with login_gov_uuid" not in caplog.text
407+
assert "unmatched subject" in caplog.text
408+
409+
assert SecurityEventToken.objects.count() == 1
410+
token = SecurityEventToken.objects.first()
411+
assert token.user is None
412+
assert token.email is None
413+
assert token.processed is True
414+
assert token.processed_at is not None
415+
assert token.event_type == event_type
416+
assert token.event_data == event_data
417+
314418
@pytest.mark.django_db
315419
def test_email_recycled(
316420
self, stt_data_analyst, email_recycled_event_data, decoded_jwt
@@ -384,9 +488,9 @@ def test_handle_event_no_sub(self, caplog):
384488

385489
@pytest.mark.django_db
386490
def test_handle_event_user_not_found(self, caplog):
387-
"""Test handling event when user is not found."""
491+
"""Test handling user-mutating event when user is not found."""
388492

389-
event_type = SecurityEventType.UNKNOWN_EVENT
493+
event_type = SecurityEventType.ACCOUNT_DISABLED
390494
login_gov_uuid = uuid.uuid4()
391495
event_data = {"subject": {"sub": str(login_gov_uuid)}}
392496
decoded_jwt = {}

tdrs-backend/tdpservice/users/api/login.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ def verify_email(self, email):
121121
"""Handle user email exceptions."""
122122
pass
123123

124+
def _sync_user_email(self, user, email):
125+
"""Sync the stored user email from trusted OIDC claims."""
126+
if not email or (user.email == email and user.username == email):
127+
return
128+
129+
user.email = email
130+
user.username = email
131+
user.save(update_fields=["email", "username"])
132+
logger.info("Updated user email from OIDC claims: %s", user.username)
133+
124134
def _handle_user(self, email, sub, auth_options):
125135
"""Handle user."""
126136
User = get_user_model()
@@ -217,14 +227,17 @@ def handle_user(self, request, id_token, decoded_token_data):
217227
# corresponding emails externally.
218228
user = CustomAuthentication.authenticate(**auth_options)
219229
logging.debug("user obj:{}".format(user))
220-
if user and (
221-
not user.is_active
222-
or user.account_approval_status == AccountApprovalStatusChoices.DEACTIVATED
223-
):
224-
raise InactiveUser(
225-
f"Login failed, user account is inactive: {user.username}"
226-
)
227-
elif not user:
230+
if user:
231+
self._sync_user_email(user, email)
232+
if (
233+
not user.is_active
234+
or user.account_approval_status
235+
== AccountApprovalStatusChoices.DEACTIVATED
236+
):
237+
raise InactiveUser(
238+
f"Login failed, user account is inactive: {user.username}"
239+
)
240+
else:
228241
user, login_msg = self._handle_user(email, sub, auth_options)
229242

230243
self.verify_email(user)

tdrs-backend/tdpservice/users/oidc.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def update_user(self, user: User, claims: dict) -> User:
8080
"""Update existing user attributes from Keycloak claims on each login."""
8181
login_gov_uuid = claims.get("login_gov_uuid")
8282
hhs_id = claims.get("hhs_id")
83+
email = claims.get("email", "").lower()
8384
changed = False
8485

8586
if login_gov_uuid and str(user.login_gov_uuid) != login_gov_uuid:
@@ -90,6 +91,11 @@ def update_user(self, user: User, claims: dict) -> User:
9091
user.hhs_id = hhs_id
9192
changed = True
9293

94+
if email and (user.email != email or user.username != email):
95+
user.email = email
96+
user.username = email
97+
changed = True
98+
9399
if changed:
94100
user.save()
95101
logger.info(

0 commit comments

Comments
 (0)