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

1import json 

2import logging 

3from datetime import timedelta 

4from urllib.parse import urlencode 

5 

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 

11 

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 

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 is_superuser=user.is_superuser, 

171 ui_language_preference=user.ui_language_preference, 

172 **get_strong_verification_fields(session, user), 

173 ) 

174 

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

176 """ 

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

178 

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

182 

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

184 # wrong password 

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

186 

187 abort_on_invalid_password(request.new_password, context) 

188 user.hashed_password = hash_password(request.new_password) 

189 

190 session.commit() 

191 

192 notify( 

193 session, 

194 user_id=user.id, 

195 topic_action="password:change", 

196 ) 

197 

198 return empty_pb2.Empty() 

199 

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

201 """ 

202 Change the user's email address. 

203 

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

205 

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

207 

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

211 

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) 

216 

217 # not a valid email 

218 if not is_valid_email(request.new_email): 

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

220 

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) 

224 

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) 

229 

230 send_email_changed_confirmation_to_new_email(session, user) 

231 

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 ) 

241 

242 # session autocommit 

243 return empty_pb2.Empty() 

244 

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

248 

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 

253 

254 return empty_pb2.Empty() 

255 

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

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

258 

259 form = request.contributor_form 

260 

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 ) 

270 

271 session.add(form) 

272 session.flush() 

273 maybe_send_contributor_form_email(session, form) 

274 

275 user.filled_contributor_form = True 

276 

277 return empty_pb2.Empty() 

278 

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

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

281 

282 return account_pb2.GetContributorFormInfoRes( 

283 filled_contributor_form=user.filled_contributor_form, 

284 ) 

285 

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) 

291 

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) 

295 

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

302 

303 if not is_known_operator(phone): 

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

305 

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

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

308 

309 token = sms.generate_random_code() 

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

311 

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 

318 

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 ) 

327 

328 return empty_pb2.Empty() 

329 

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

331 

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) 

335 

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) 

339 

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

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

342 

343 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

345 

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) 

350 

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 ) 

366 

367 user.phone_verification_token = None 

368 user.phone_verification_verified = now() 

369 user.phone_verification_attempts = 0 

370 

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 ) 

379 

380 return empty_pb2.Empty() 

381 

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

383 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

385 

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) 

394 

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

396 

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 ) 

418 

419 if response.status_code != 200: 

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

421 

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 ) 

432 

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) 

440 

441 return account_pb2.InitiateStrongVerificationRes( 

442 verification_attempt_token=verification_attempt_token, 

443 redirect_url=redirect_url, 

444 ) 

445 

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 ) 

467 

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 

496 

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

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

499 

500 return empty_pb2.Empty() 

501 

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

503 """ 

504 Triggers email with token to confirm deletion 

505 

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) 

510 

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

512 

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) 

519 

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

521 

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) 

531 

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

533 

534 return empty_pb2.Empty() 

535 

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

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

538 

539 notes = ( 

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

541 .scalars() 

542 .all() 

543 ) 

544 

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

546 

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

550 

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 ) 

564 

565 (token, token_expiry) = context.token 

566 

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 ) 

579 

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 ) 

584 

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

586 (token, token_expiry) = context.token 

587 

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

599 

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) 

603 

604 (token, token_expiry) = context.token 

605 

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

616 

617 

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 

627 

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 

658 

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 )