Coverage for src/couchers/servicers/account.py: 95%

245 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-10-15 13:03 +0000

1import json 

2import logging 

3from datetime import timedelta 

4 

5import grpc 

6import requests 

7from google.protobuf import empty_pb2 

8from sqlalchemy.sql import func, update 

9from user_agents import parse as user_agents_parse 

10 

11from couchers import errors 

12from couchers.config import config 

13from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME 

14from couchers.crypto import ( 

15 b64decode, 

16 b64encode, 

17 hash_password, 

18 simple_decrypt, 

19 simple_encrypt, 

20 urlsafe_secure_token, 

21 verify_password, 

22 verify_token, 

23) 

24from couchers.helpers.geoip import geoip_approximate_location 

25from couchers.jobs.enqueue import queue_job 

26from couchers.metrics import ( 

27 account_deletion_initiations_counter, 

28 strong_verification_completions_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 

64 

65logger = logging.getLogger(__name__) 

66logger.setLevel(logging.DEBUG) 

67 

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} 

74 

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} 

81 

82MAX_PAGINATION_LENGTH = 50 

83 

84 

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 

97 

98 

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 ) 

106 

107 

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 

127 

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 

132 

133 out["has_strong_verification"] = attempt.has_strong_verification(db_user) 

134 

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 

140 

141 

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) 

148 

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) 

152 

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) 

156 

157 

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() 

161 

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 **get_strong_verification_fields(session, user), 

171 ) 

172 

173 def ChangePasswordV2(self, request, context, session): 

174 """ 

175 Changes the user's password. They have to confirm their old password just in case. 

176 

177 If they didn't have an old password previously, then we don't check that. 

178 """ 

179 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

180 

181 if not verify_password(user.hashed_password, request.old_password): 

182 # wrong password 

183 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD) 

184 

185 abort_on_invalid_password(request.new_password, context) 

186 user.hashed_password = hash_password(request.new_password) 

187 

188 session.commit() 

189 

190 notify( 

191 session, 

192 user_id=user.id, 

193 topic_action="password:change", 

194 ) 

195 

196 return empty_pb2.Empty() 

197 

198 def ChangeEmailV2(self, request, context, session): 

199 """ 

200 Change the user's email address. 

201 

202 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one. 

203 

204 Otherwise they need to confirm twice, via an email sent to each of their old and new emails. 

205 

206 In all confirmation emails, the user must click on the confirmation link. 

207 """ 

208 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

209 

210 # check password first 

211 if not verify_password(user.hashed_password, request.password): 

212 # wrong password 

213 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD) 

214 

215 # not a valid email 

216 if not is_valid_email(request.new_email): 

217 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL) 

218 

219 # email already in use (possibly by this user) 

220 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none(): 

221 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL) 

222 

223 user.new_email = request.new_email 

224 user.new_email_token = urlsafe_secure_token() 

225 user.new_email_token_created = now() 

226 user.new_email_token_expiry = now() + timedelta(hours=2) 

227 

228 send_email_changed_confirmation_to_new_email(session, user) 

229 

230 # will still go into old email 

231 notify( 

232 session, 

233 user_id=user.id, 

234 topic_action="email_address:change", 

235 data=notification_data_pb2.EmailAddressChange( 

236 new_email=request.new_email, 

237 ), 

238 ) 

239 

240 # session autocommit 

241 return empty_pb2.Empty() 

242 

243 def FillContributorForm(self, request, context, session): 

244 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

245 

246 form = request.contributor_form 

247 

248 form = ContributorForm( 

249 user=user, 

250 ideas=form.ideas or None, 

251 features=form.features or None, 

252 experience=form.experience or None, 

253 contribute=contributeoption2sql[form.contribute], 

254 contribute_ways=form.contribute_ways, 

255 expertise=form.expertise or None, 

256 ) 

257 

258 session.add(form) 

259 session.flush() 

260 maybe_send_contributor_form_email(session, form) 

261 

262 user.filled_contributor_form = True 

263 

264 return empty_pb2.Empty() 

265 

266 def GetContributorFormInfo(self, request, context, session): 

267 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

268 

269 return account_pb2.GetContributorFormInfoRes( 

270 filled_contributor_form=user.filled_contributor_form, 

271 ) 

272 

273 def ChangePhone(self, request, context, session): 

274 phone = request.phone 

275 # early quick validation 

276 if phone and not is_e164_format(phone): 

277 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PHONE) 

278 

279 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

280 if not user.has_donated: 

281 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_DONATED) 

282 

283 if not phone: 

284 user.phone = None 

285 user.phone_verification_verified = None 

286 user.phone_verification_token = None 

287 user.phone_verification_attempts = 0 

288 return empty_pb2.Empty() 

289 

290 if not is_known_operator(phone): 

291 context.abort(grpc.StatusCode.UNIMPLEMENTED, errors.UNRECOGNIZED_PHONE_NUMBER) 

292 

293 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL: 

294 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.REVERIFICATION_TOO_EARLY) 

295 

296 token = sms.generate_random_code() 

297 result = sms.send_sms(phone, sms.format_message(token)) 

298 

299 if result == "success": 

300 user.phone = phone 

301 user.phone_verification_verified = None 

302 user.phone_verification_token = token 

303 user.phone_verification_sent = now() 

304 user.phone_verification_attempts = 0 

305 

306 notify( 

307 session, 

308 user_id=user.id, 

309 topic_action="phone_number:change", 

310 data=notification_data_pb2.PhoneNumberChange( 

311 phone=phone, 

312 ), 

313 ) 

314 

315 return empty_pb2.Empty() 

316 

317 context.abort(grpc.StatusCode.UNIMPLEMENTED, result) 

318 

319 def VerifyPhone(self, request, context, session): 

320 if not sms.looks_like_a_code(request.token): 

321 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.WRONG_SMS_CODE) 

322 

323 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

324 if user.phone_verification_token is None: 

325 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION) 

326 

327 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 

328 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION) 

329 

330 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

331 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.TOO_MANY_SMS_CODE_ATTEMPTS) 

332 

333 if not verify_token(request.token, user.phone_verification_token): 

334 user.phone_verification_attempts += 1 

335 session.commit() 

336 context.abort(grpc.StatusCode.NOT_FOUND, errors.WRONG_SMS_CODE) 

337 

338 # Delete verifications from everyone else that has this number 

339 session.execute( 

340 update(User) 

341 .where(User.phone == user.phone) 

342 .where(User.id != context.user_id) 

343 .values( 

344 { 

345 "phone_verification_verified": None, 

346 "phone_verification_attempts": 0, 

347 "phone_verification_token": None, 

348 "phone": None, 

349 } 

350 ) 

351 .execution_options(synchronize_session=False) 

352 ) 

353 

354 user.phone_verification_token = None 

355 user.phone_verification_verified = now() 

356 user.phone_verification_attempts = 0 

357 

358 notify( 

359 session, 

360 user_id=user.id, 

361 topic_action="phone_number:verify", 

362 data=notification_data_pb2.PhoneNumberVerify( 

363 phone=user.phone, 

364 ), 

365 ) 

366 

367 return empty_pb2.Empty() 

368 

369 def InitiateStrongVerification(self, request, context, session): 

370 if not config["ENABLE_STRONG_VERIFICATION"]: 

371 context.abort(grpc.StatusCode.UNAVAILABLE, errors.STRONG_VERIFICATION_DISABLED) 

372 

373 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

374 existing_verification = session.execute( 

375 select(StrongVerificationAttempt) 

376 .where(StrongVerificationAttempt.user_id == user.id) 

377 .where(StrongVerificationAttempt.is_valid) 

378 ).scalar_one_or_none() 

379 if existing_verification: 

380 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.STRONG_VERIFICATION_ALREADY_VERIFIED) 

381 

382 strong_verification_initiations_counter.labels(user.gender).inc() 

383 

384 verification_attempt_token = urlsafe_secure_token() 

385 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise 

386 reference = b64encode( 

387 simple_encrypt( 

388 "iris_callback", 

389 verification_pb2.VerificationReferencePayload( 

390 verification_attempt_token=verification_attempt_token, 

391 user_id=user.id, 

392 ).SerializeToString(), 

393 ) 

394 ) 

395 response = requests.post( 

396 "https://passportreader.app/api/v1/session.create", 

397 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]), 

398 json={ 

399 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook", 

400 "face_verification": False, 

401 "reference": reference, 

402 }, 

403 timeout=10, 

404 ) 

405 if response.status_code != 200: 

406 raise Exception(f"Iris didn't return 200: {response.text}") 

407 iris_session_id = response.json()["id"] 

408 token = response.json()["token"] 

409 url = f"iris:///?token={token}" 

410 verification_attempt = StrongVerificationAttempt( 

411 user_id=user.id, 

412 verification_attempt_token=verification_attempt_token, 

413 iris_session_id=iris_session_id, 

414 iris_token=token, 

415 ) 

416 session.add(verification_attempt) 

417 

418 return account_pb2.InitiateStrongVerificationRes( 

419 verification_attempt_token=verification_attempt_token, 

420 iris_url=url, 

421 ) 

422 

423 def GetStrongVerificationAttemptStatus(self, request, context, session): 

424 verification_attempt = session.execute( 

425 select(StrongVerificationAttempt) 

426 .where(StrongVerificationAttempt.user_id == context.user_id) 

427 .where(StrongVerificationAttempt.is_visible) 

428 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token) 

429 ).scalar_one_or_none() 

430 if not verification_attempt: 

431 context.abort(grpc.StatusCode.NOT_FOUND, errors.STRONG_VERIFICATION_ATTEMPT_NOT_FOUND) 

432 status_to_pb = { 

433 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

434 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

435 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

436 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

437 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

438 } 

439 return account_pb2.GetStrongVerificationAttemptStatusRes( 

440 status=status_to_pb.get( 

441 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

442 ), 

443 ) 

444 

445 def DeleteStrongVerificationData(self, request, context, session): 

446 verification_attempts = ( 

447 session.execute( 

448 select(StrongVerificationAttempt) 

449 .where(StrongVerificationAttempt.user_id == context.user_id) 

450 .where(StrongVerificationAttempt.has_full_data) 

451 ) 

452 .scalars() 

453 .all() 

454 ) 

455 for verification_attempt in verification_attempts: 

456 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

457 verification_attempt.has_full_data = False 

458 verification_attempt.passport_encrypted_data = None 

459 verification_attempt.passport_date_of_birth = None 

460 verification_attempt.passport_sex = None 

461 session.flush() 

462 # double check: 

463 verification_attempts = ( 

464 session.execute( 

465 select(StrongVerificationAttempt) 

466 .where(StrongVerificationAttempt.user_id == context.user_id) 

467 .where(StrongVerificationAttempt.has_full_data) 

468 ) 

469 .scalars() 

470 .all() 

471 ) 

472 assert len(verification_attempts) == 0 

473 

474 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

475 strong_verification_data_deletions_counter.labels(user.gender).inc() 

476 

477 return empty_pb2.Empty() 

478 

479 def DeleteAccount(self, request, context, session): 

480 """ 

481 Triggers email with token to confirm deletion 

482 

483 Frontend should confirm via unique string (i.e. username) before this is called 

484 """ 

485 if not request.confirm: 

486 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_ACCOUNT_DELETE) 

487 

488 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

489 

490 reason = request.reason.strip() 

491 if reason: 

492 reason = AccountDeletionReason(user_id=user.id, reason=reason) 

493 session.add(reason) 

494 session.flush() 

495 send_account_deletion_report_email(session, reason) 

496 

497 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)) 

498 

499 notify( 

500 session, 

501 user_id=user.id, 

502 topic_action="account_deletion:start", 

503 data=notification_data_pb2.AccountDeletionStart( 

504 deletion_token=token.token, 

505 ), 

506 ) 

507 session.add(token) 

508 

509 account_deletion_initiations_counter.labels(user.gender).inc() 

510 

511 return empty_pb2.Empty() 

512 

513 def ListModNotes(self, request, context, session): 

514 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

515 

516 notes = ( 

517 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc())) 

518 .scalars() 

519 .all() 

520 ) 

521 

522 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes]) 

523 

524 def ListActiveSessions(self, request, context, session): 

525 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

526 page_token = dt_from_page_token(request.page_token) if request.page_token else now() 

527 

528 user_sessions = ( 

529 session.execute( 

530 select(UserSession) 

531 .where(UserSession.user_id == context.user_id) 

532 .where(UserSession.is_valid) 

533 .where(UserSession.is_api_key == False) 

534 .where(UserSession.last_seen <= page_token) 

535 .order_by(UserSession.last_seen.desc()) 

536 .limit(page_size + 1) 

537 ) 

538 .scalars() 

539 .all() 

540 ) 

541 

542 (token, token_expiry) = context.token 

543 

544 def _active_session_to_pb(user_session): 

545 user_agent = user_agents_parse(user_session.user_agent or "") 

546 return account_pb2.ActiveSession( 

547 created=Timestamp_from_datetime(user_session.created), 

548 expiry=Timestamp_from_datetime(user_session.expiry), 

549 last_seen=Timestamp_from_datetime(user_session.last_seen), 

550 operating_system=user_agent.os.family, 

551 browser=user_agent.browser.family, 

552 device=user_agent.device.family, 

553 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown", 

554 is_current_session=user_session.token == token, 

555 ) 

556 

557 return account_pb2.ListActiveSessionsRes( 

558 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])), 

559 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None, 

560 ) 

561 

562 def LogOutSession(self, request, context, session): 

563 (token, token_expiry) = context.token 

564 

565 session.execute( 

566 update(UserSession) 

567 .where(UserSession.token != token) 

568 .where(UserSession.user_id == context.user_id) 

569 .where(UserSession.is_valid) 

570 .where(UserSession.is_api_key == False) 

571 .where(UserSession.created == to_aware_datetime(request.created)) 

572 .values(expiry=func.now()) 

573 .execution_options(synchronize_session=False) 

574 ) 

575 return empty_pb2.Empty() 

576 

577 def LogOutOtherSessions(self, request, context, session): 

578 if not request.confirm: 

579 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_LOGOUT_OTHER_SESSIONS) 

580 

581 (token, token_expiry) = context.token 

582 

583 session.execute( 

584 update(UserSession) 

585 .where(UserSession.token != token) 

586 .where(UserSession.user_id == context.user_id) 

587 .where(UserSession.is_valid) 

588 .where(UserSession.is_api_key == False) 

589 .values(expiry=func.now()) 

590 .execution_options(synchronize_session=False) 

591 ) 

592 return empty_pb2.Empty() 

593 

594 

595class Iris(iris_pb2_grpc.IrisServicer): 

596 def Webhook(self, request, context, session): 

597 json_data = json.loads(request.data) 

598 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

599 simple_decrypt("iris_callback", b64decode(json_data["session_referenace"])) 

600 ) 

601 # if we make it past the decrypt, we consider this webhook authenticated 

602 verification_attempt_token = reference_payload.verification_attempt_token 

603 user_id = reference_payload.user_id 

604 

605 verification_attempt = session.execute( 

606 select(StrongVerificationAttempt) 

607 .where(StrongVerificationAttempt.user_id == reference_payload.user_id) 

608 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token) 

609 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"]) 

610 ).scalar_one() 

611 iris_status = json_data["session_state"] 

612 session.add( 

613 StrongVerificationCallbackEvent( 

614 verification_attempt_id=verification_attempt.id, 

615 iris_status=iris_status, 

616 ) 

617 ) 

618 if iris_status == "INITIATED": 

619 # the user opened the session in the app 

620 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

621 elif iris_status == "COMPLETED": 

622 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

623 elif iris_status == "APPROVED": 

624 strong_verification_completions_counter.inc() 

625 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

626 session.commit() 

627 # background worker will go and sort this one out 

628 queue_job( 

629 session, 

630 job_type="finalize_strong_verification", 

631 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id), 

632 ) 

633 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]: 

634 verification_attempt.status = StrongVerificationAttemptStatus.failed 

635 

636 return httpbody_pb2.HttpBody( 

637 content_type="application/json", 

638 # json.dumps escapes non-ascii characters 

639 data=json.dumps({"success": True}).encode("ascii"), 

640 )