Coverage for src/couchers/servicers/account.py: 95%
250 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-03-24 14:08 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-03-24 14:08 +0000
1import json
2import logging
3from datetime import timedelta
4from urllib.parse import urlencode
6import grpc
7import requests
8from google.protobuf import empty_pb2
9from sqlalchemy.sql import func, update
10from user_agents import parse as user_agents_parse
12from couchers import errors, urls
13from couchers.config import config
14from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME
15from couchers.crypto import (
16 b64decode,
17 b64encode,
18 hash_password,
19 simple_decrypt,
20 simple_encrypt,
21 urlsafe_secure_token,
22 verify_password,
23 verify_token,
24)
25from couchers.helpers.geoip import geoip_approximate_location
26from couchers.jobs.enqueue import queue_job
27from couchers.metrics import (
28 account_deletion_initiations_counter,
29 strong_verification_data_deletions_counter,
30 strong_verification_initiations_counter,
31)
32from couchers.models import (
33 AccountDeletionReason,
34 AccountDeletionToken,
35 ContributeOption,
36 ContributorForm,
37 ModNote,
38 StrongVerificationAttempt,
39 StrongVerificationAttemptStatus,
40 StrongVerificationCallbackEvent,
41 User,
42 UserSession,
43)
44from couchers.notifications.notify import notify
45from couchers.phone import sms
46from couchers.phone.check import is_e164_format, is_known_operator
47from couchers.sql import couchers_select as select
48from couchers.tasks import (
49 maybe_send_contributor_form_email,
50 send_account_deletion_report_email,
51 send_email_changed_confirmation_to_new_email,
52)
53from couchers.utils import (
54 Timestamp_from_datetime,
55 dt_from_page_token,
56 dt_to_page_token,
57 is_valid_email,
58 now,
59 to_aware_datetime,
60)
61from proto import account_pb2, account_pb2_grpc, api_pb2, auth_pb2, iris_pb2_grpc, notification_data_pb2
62from proto.google.api import httpbody_pb2
63from proto.internal import jobs_pb2, verification_pb2
65logger = logging.getLogger(__name__)
66logger.setLevel(logging.DEBUG)
68contributeoption2sql = {
69 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
70 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
71 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
72 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
73}
75contributeoption2api = {
76 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
77 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
78 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
79 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
80}
82MAX_PAGINATION_LENGTH = 50
85def has_strong_verification(session, user):
86 attempt = session.execute(
87 select(StrongVerificationAttempt)
88 .where(StrongVerificationAttempt.user_id == user.id)
89 .where(StrongVerificationAttempt.is_valid)
90 .order_by(StrongVerificationAttempt.passport_expiry_datetime.desc())
91 .limit(1)
92 ).scalar_one_or_none()
93 if attempt:
94 assert attempt.is_valid
95 return attempt.has_strong_verification(user)
96 return False
99def mod_note_to_pb(note: ModNote):
100 return account_pb2.ModNote(
101 note_id=note.id,
102 note_content=note.note_content,
103 created=Timestamp_from_datetime(note.created),
104 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
105 )
108def get_strong_verification_fields(session, db_user):
109 out = dict(
110 birthdate_verification_status=api_pb2.BIRTHDATE_VERIFICATION_STATUS_UNVERIFIED,
111 gender_verification_status=api_pb2.GENDER_VERIFICATION_STATUS_UNVERIFIED,
112 has_strong_verification=False,
113 )
114 attempt = session.execute(
115 select(StrongVerificationAttempt)
116 .where(StrongVerificationAttempt.user_id == db_user.id)
117 .where(StrongVerificationAttempt.is_valid)
118 .order_by(StrongVerificationAttempt.passport_expiry_datetime.desc())
119 .limit(1)
120 ).scalar_one_or_none()
121 if attempt:
122 assert attempt.is_valid
123 if attempt.matches_birthdate(db_user):
124 out["birthdate_verification_status"] = api_pb2.BIRTHDATE_VERIFICATION_STATUS_VERIFIED
125 else:
126 out["birthdate_verification_status"] = api_pb2.BIRTHDATE_VERIFICATION_STATUS_MISMATCH
128 if attempt.matches_gender(db_user):
129 out["gender_verification_status"] = api_pb2.GENDER_VERIFICATION_STATUS_VERIFIED
130 else:
131 out["gender_verification_status"] = api_pb2.GENDER_VERIFICATION_STATUS_MISMATCH
133 out["has_strong_verification"] = attempt.has_strong_verification(db_user)
135 assert out["has_strong_verification"] == (
136 out["birthdate_verification_status"] == api_pb2.BIRTHDATE_VERIFICATION_STATUS_VERIFIED
137 and out["gender_verification_status"] == api_pb2.GENDER_VERIFICATION_STATUS_VERIFIED
138 )
139 return out
142def abort_on_invalid_password(password, context):
143 """
144 Internal utility function: given a password, aborts if password is unforgivably insecure
145 """
146 if len(password) < 8:
147 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_SHORT)
149 if len(password) > 256:
150 # Hey, what are you trying to do? Give us a DDOS attack?
151 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_LONG)
153 # check for most common weak passwords (not meant to be an exhaustive check!)
154 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
155 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INSECURE_PASSWORD)
158class Account(account_pb2_grpc.AccountServicer):
159 def GetAccountInfo(self, request, context, session):
160 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
162 return account_pb2.GetAccountInfoRes(
163 username=user.username,
164 email=user.email,
165 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
166 has_donated=user.has_donated,
167 phone_verified=user.phone_is_verified,
168 profile_complete=user.has_completed_profile,
169 timezone=user.timezone,
170 is_superuser=user.is_superuser,
171 ui_language_preference=user.ui_language_preference,
172 **get_strong_verification_fields(session, user),
173 )
175 def ChangePasswordV2(self, request, context, session):
176 """
177 Changes the user's password. They have to confirm their old password just in case.
179 If they didn't have an old password previously, then we don't check that.
180 """
181 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
183 if not verify_password(user.hashed_password, request.old_password):
184 # wrong password
185 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
187 abort_on_invalid_password(request.new_password, context)
188 user.hashed_password = hash_password(request.new_password)
190 session.commit()
192 notify(
193 session,
194 user_id=user.id,
195 topic_action="password:change",
196 )
198 return empty_pb2.Empty()
200 def ChangeEmailV2(self, request, context, session):
201 """
202 Change the user's email address.
204 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
206 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
208 In all confirmation emails, the user must click on the confirmation link.
209 """
210 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
212 # check password first
213 if not verify_password(user.hashed_password, request.password):
214 # wrong password
215 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
217 # not a valid email
218 if not is_valid_email(request.new_email):
219 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
221 # email already in use (possibly by this user)
222 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
223 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
225 user.new_email = request.new_email
226 user.new_email_token = urlsafe_secure_token()
227 user.new_email_token_created = now()
228 user.new_email_token_expiry = now() + timedelta(hours=2)
230 send_email_changed_confirmation_to_new_email(session, user)
232 # will still go into old email
233 notify(
234 session,
235 user_id=user.id,
236 topic_action="email_address:change",
237 data=notification_data_pb2.EmailAddressChange(
238 new_email=request.new_email,
239 ),
240 )
242 # session autocommit
243 return empty_pb2.Empty()
245 def ChangeLanguagePreference(self, request, context, session):
246 # select the user from the db
247 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
249 # update the user's preference
250 user.ui_language_preference = request.ui_language_preference
251 # setting this on context will update the cookie (via interceptors)?
252 context.ui_language_preference = request.ui_language_preference
254 return empty_pb2.Empty()
256 def FillContributorForm(self, request, context, session):
257 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
259 form = request.contributor_form
261 form = ContributorForm(
262 user=user,
263 ideas=form.ideas or None,
264 features=form.features or None,
265 experience=form.experience or None,
266 contribute=contributeoption2sql[form.contribute],
267 contribute_ways=form.contribute_ways,
268 expertise=form.expertise or None,
269 )
271 session.add(form)
272 session.flush()
273 maybe_send_contributor_form_email(session, form)
275 user.filled_contributor_form = True
277 return empty_pb2.Empty()
279 def GetContributorFormInfo(self, request, context, session):
280 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
282 return account_pb2.GetContributorFormInfoRes(
283 filled_contributor_form=user.filled_contributor_form,
284 )
286 def ChangePhone(self, request, context, session):
287 phone = request.phone
288 # early quick validation
289 if phone and not is_e164_format(phone):
290 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PHONE)
292 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
293 if not user.has_donated:
294 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_DONATED)
296 if not phone:
297 user.phone = None
298 user.phone_verification_verified = None
299 user.phone_verification_token = None
300 user.phone_verification_attempts = 0
301 return empty_pb2.Empty()
303 if not is_known_operator(phone):
304 context.abort(grpc.StatusCode.UNIMPLEMENTED, errors.UNRECOGNIZED_PHONE_NUMBER)
306 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
307 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.REVERIFICATION_TOO_EARLY)
309 token = sms.generate_random_code()
310 result = sms.send_sms(phone, sms.format_message(token))
312 if result == "success":
313 user.phone = phone
314 user.phone_verification_verified = None
315 user.phone_verification_token = token
316 user.phone_verification_sent = now()
317 user.phone_verification_attempts = 0
319 notify(
320 session,
321 user_id=user.id,
322 topic_action="phone_number:change",
323 data=notification_data_pb2.PhoneNumberChange(
324 phone=phone,
325 ),
326 )
328 return empty_pb2.Empty()
330 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
332 def VerifyPhone(self, request, context, session):
333 if not sms.looks_like_a_code(request.token):
334 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.WRONG_SMS_CODE)
336 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
337 if user.phone_verification_token is None:
338 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
340 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME:
341 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
343 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
344 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.TOO_MANY_SMS_CODE_ATTEMPTS)
346 if not verify_token(request.token, user.phone_verification_token):
347 user.phone_verification_attempts += 1
348 session.commit()
349 context.abort(grpc.StatusCode.NOT_FOUND, errors.WRONG_SMS_CODE)
351 # Delete verifications from everyone else that has this number
352 session.execute(
353 update(User)
354 .where(User.phone == user.phone)
355 .where(User.id != context.user_id)
356 .values(
357 {
358 "phone_verification_verified": None,
359 "phone_verification_attempts": 0,
360 "phone_verification_token": None,
361 "phone": None,
362 }
363 )
364 .execution_options(synchronize_session=False)
365 )
367 user.phone_verification_token = None
368 user.phone_verification_verified = now()
369 user.phone_verification_attempts = 0
371 notify(
372 session,
373 user_id=user.id,
374 topic_action="phone_number:verify",
375 data=notification_data_pb2.PhoneNumberVerify(
376 phone=user.phone,
377 ),
378 )
380 return empty_pb2.Empty()
382 def InitiateStrongVerification(self, request, context, session):
383 if not config["ENABLE_STRONG_VERIFICATION"]:
384 context.abort(grpc.StatusCode.UNAVAILABLE, errors.STRONG_VERIFICATION_DISABLED)
386 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
387 existing_verification = session.execute(
388 select(StrongVerificationAttempt)
389 .where(StrongVerificationAttempt.user_id == user.id)
390 .where(StrongVerificationAttempt.is_valid)
391 ).scalar_one_or_none()
392 if existing_verification:
393 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.STRONG_VERIFICATION_ALREADY_VERIFIED)
395 strong_verification_initiations_counter.labels(user.gender).inc()
397 verification_attempt_token = urlsafe_secure_token()
398 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
399 reference = b64encode(
400 simple_encrypt(
401 "iris_callback",
402 verification_pb2.VerificationReferencePayload(
403 verification_attempt_token=verification_attempt_token,
404 user_id=user.id,
405 ).SerializeToString(),
406 )
407 )
408 response = requests.post(
409 "https://passportreader.app/api/v1/session.create",
410 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
411 json={
412 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
413 "face_verification": False,
414 "reference": reference,
415 },
416 timeout=10,
417 )
419 if response.status_code != 200:
420 raise Exception(f"Iris didn't return 200: {response.text}")
422 iris_session_id = response.json()["id"]
423 token = response.json()["token"]
424 session.add(
425 StrongVerificationAttempt(
426 user_id=user.id,
427 verification_attempt_token=verification_attempt_token,
428 iris_session_id=iris_session_id,
429 iris_token=token,
430 )
431 )
433 redirect_params = {
434 "token": token,
435 "redirect_url": urls.complete_strong_verification_url(
436 verification_attempt_token=verification_attempt_token
437 ),
438 }
439 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
441 return account_pb2.InitiateStrongVerificationRes(
442 verification_attempt_token=verification_attempt_token,
443 redirect_url=redirect_url,
444 )
446 def GetStrongVerificationAttemptStatus(self, request, context, session):
447 verification_attempt = session.execute(
448 select(StrongVerificationAttempt)
449 .where(StrongVerificationAttempt.user_id == context.user_id)
450 .where(StrongVerificationAttempt.is_visible)
451 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
452 ).scalar_one_or_none()
453 if not verification_attempt:
454 context.abort(grpc.StatusCode.NOT_FOUND, errors.STRONG_VERIFICATION_ATTEMPT_NOT_FOUND)
455 status_to_pb = {
456 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
457 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
458 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
459 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
460 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
461 }
462 return account_pb2.GetStrongVerificationAttemptStatusRes(
463 status=status_to_pb.get(
464 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
465 ),
466 )
468 def DeleteStrongVerificationData(self, request, context, session):
469 verification_attempts = (
470 session.execute(
471 select(StrongVerificationAttempt)
472 .where(StrongVerificationAttempt.user_id == context.user_id)
473 .where(StrongVerificationAttempt.has_full_data)
474 )
475 .scalars()
476 .all()
477 )
478 for verification_attempt in verification_attempts:
479 verification_attempt.status = StrongVerificationAttemptStatus.deleted
480 verification_attempt.has_full_data = False
481 verification_attempt.passport_encrypted_data = None
482 verification_attempt.passport_date_of_birth = None
483 verification_attempt.passport_sex = None
484 session.flush()
485 # double check:
486 verification_attempts = (
487 session.execute(
488 select(StrongVerificationAttempt)
489 .where(StrongVerificationAttempt.user_id == context.user_id)
490 .where(StrongVerificationAttempt.has_full_data)
491 )
492 .scalars()
493 .all()
494 )
495 assert len(verification_attempts) == 0
497 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
498 strong_verification_data_deletions_counter.labels(user.gender).inc()
500 return empty_pb2.Empty()
502 def DeleteAccount(self, request, context, session):
503 """
504 Triggers email with token to confirm deletion
506 Frontend should confirm via unique string (i.e. username) before this is called
507 """
508 if not request.confirm:
509 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_ACCOUNT_DELETE)
511 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
513 reason = request.reason.strip()
514 if reason:
515 reason = AccountDeletionReason(user_id=user.id, reason=reason)
516 session.add(reason)
517 session.flush()
518 send_account_deletion_report_email(session, reason)
520 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
522 notify(
523 session,
524 user_id=user.id,
525 topic_action="account_deletion:start",
526 data=notification_data_pb2.AccountDeletionStart(
527 deletion_token=token.token,
528 ),
529 )
530 session.add(token)
532 account_deletion_initiations_counter.labels(user.gender).inc()
534 return empty_pb2.Empty()
536 def ListModNotes(self, request, context, session):
537 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
539 notes = (
540 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
541 .scalars()
542 .all()
543 )
545 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
547 def ListActiveSessions(self, request, context, session):
548 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
549 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
551 user_sessions = (
552 session.execute(
553 select(UserSession)
554 .where(UserSession.user_id == context.user_id)
555 .where(UserSession.is_valid)
556 .where(UserSession.is_api_key == False)
557 .where(UserSession.last_seen <= page_token)
558 .order_by(UserSession.last_seen.desc())
559 .limit(page_size + 1)
560 )
561 .scalars()
562 .all()
563 )
565 (token, token_expiry) = context.token
567 def _active_session_to_pb(user_session):
568 user_agent = user_agents_parse(user_session.user_agent or "")
569 return account_pb2.ActiveSession(
570 created=Timestamp_from_datetime(user_session.created),
571 expiry=Timestamp_from_datetime(user_session.expiry),
572 last_seen=Timestamp_from_datetime(user_session.last_seen),
573 operating_system=user_agent.os.family,
574 browser=user_agent.browser.family,
575 device=user_agent.device.family,
576 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
577 is_current_session=user_session.token == token,
578 )
580 return account_pb2.ListActiveSessionsRes(
581 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
582 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
583 )
585 def LogOutSession(self, request, context, session):
586 (token, token_expiry) = context.token
588 session.execute(
589 update(UserSession)
590 .where(UserSession.token != token)
591 .where(UserSession.user_id == context.user_id)
592 .where(UserSession.is_valid)
593 .where(UserSession.is_api_key == False)
594 .where(UserSession.created == to_aware_datetime(request.created))
595 .values(expiry=func.now())
596 .execution_options(synchronize_session=False)
597 )
598 return empty_pb2.Empty()
600 def LogOutOtherSessions(self, request, context, session):
601 if not request.confirm:
602 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_LOGOUT_OTHER_SESSIONS)
604 (token, token_expiry) = context.token
606 session.execute(
607 update(UserSession)
608 .where(UserSession.token != token)
609 .where(UserSession.user_id == context.user_id)
610 .where(UserSession.is_valid)
611 .where(UserSession.is_api_key == False)
612 .values(expiry=func.now())
613 .execution_options(synchronize_session=False)
614 )
615 return empty_pb2.Empty()
618class Iris(iris_pb2_grpc.IrisServicer):
619 def Webhook(self, request, context, session):
620 json_data = json.loads(request.data)
621 reference_payload = verification_pb2.VerificationReferencePayload.FromString(
622 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
623 )
624 # if we make it past the decrypt, we consider this webhook authenticated
625 verification_attempt_token = reference_payload.verification_attempt_token
626 user_id = reference_payload.user_id
628 verification_attempt = session.execute(
629 select(StrongVerificationAttempt)
630 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
631 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
632 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
633 ).scalar_one()
634 iris_status = json_data["session_state"]
635 session.add(
636 StrongVerificationCallbackEvent(
637 verification_attempt_id=verification_attempt.id,
638 iris_status=iris_status,
639 )
640 )
641 if iris_status == "INITIATED":
642 # the user opened the session in the app
643 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
644 elif iris_status == "COMPLETED":
645 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
646 elif iris_status == "APPROVED":
647 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
648 session.commit()
649 # background worker will go and sort this one out
650 queue_job(
651 session,
652 job_type="finalize_strong_verification",
653 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
654 priority=8,
655 )
656 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
657 verification_attempt.status = StrongVerificationAttemptStatus.failed
659 return httpbody_pb2.HttpBody(
660 content_type="application/json",
661 # json.dumps escapes non-ascii characters
662 data=json.dumps({"success": True}).encode("ascii"),
663 )