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

307 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-08-28 14:55 +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 generate_invite_code, 

19 hash_password, 

20 simple_decrypt, 

21 simple_encrypt, 

22 urlsafe_secure_token, 

23 verify_password, 

24 verify_token, 

25) 

26from couchers.helpers.geoip import geoip_approximate_location 

27from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

28from couchers.jobs.enqueue import queue_job 

29from couchers.materialized_views import LiteUser 

30from couchers.metrics import ( 

31 account_deletion_initiations_counter, 

32 strong_verification_data_deletions_counter, 

33 strong_verification_initiations_counter, 

34) 

35from couchers.models import ( 

36 AccountDeletionReason, 

37 AccountDeletionToken, 

38 ContributeOption, 

39 ContributorForm, 

40 HostRequest, 

41 HostRequestStatus, 

42 InviteCode, 

43 ModNote, 

44 ProfilePublicVisibility, 

45 StrongVerificationAttempt, 

46 StrongVerificationAttemptStatus, 

47 StrongVerificationCallbackEvent, 

48 User, 

49 UserSession, 

50 Volunteer, 

51) 

52from couchers.notifications.notify import notify 

53from couchers.phone import sms 

54from couchers.phone.check import is_e164_format, is_known_operator 

55from couchers.servicers.api import lite_user_to_pb 

56from couchers.servicers.references import get_pending_references_to_write, reftype2api 

57from couchers.sql import couchers_select as select 

58from couchers.tasks import ( 

59 maybe_send_contributor_form_email, 

60 send_account_deletion_report_email, 

61 send_email_changed_confirmation_to_new_email, 

62) 

63from couchers.utils import ( 

64 Timestamp_from_datetime, 

65 create_lang_cookie, 

66 date_to_api, 

67 dt_from_page_token, 

68 dt_to_page_token, 

69 is_valid_email, 

70 now, 

71 to_aware_datetime, 

72) 

73from proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2 

74from proto.google.api import httpbody_pb2 

75from proto.internal import jobs_pb2, verification_pb2 

76 

77logger = logging.getLogger(__name__) 

78logger.setLevel(logging.DEBUG) 

79 

80contributeoption2sql = { 

81 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

82 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

83 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

84 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

85} 

86 

87contributeoption2api = { 

88 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

89 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

90 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

91 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

92} 

93 

94profilepublicitysetting2sql = { 

95 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

96 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

97 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

98 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

99 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

100 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

101} 

102 

103profilepublicitysetting2api = { 

104 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

105 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

106 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

107 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

108 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

109 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

110} 

111 

112MAX_PAGINATION_LENGTH = 50 

113 

114 

115def mod_note_to_pb(note: ModNote): 

116 return account_pb2.ModNote( 

117 note_id=note.id, 

118 note_content=note.note_content, 

119 created=Timestamp_from_datetime(note.created), 

120 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None, 

121 ) 

122 

123 

124def abort_on_invalid_password(password, context): 

125 """ 

126 Internal utility function: given a password, aborts if password is unforgivably insecure 

127 """ 

128 if len(password) < 8: 

129 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_SHORT) 

130 

131 if len(password) > 256: 

132 # Hey, what are you trying to do? Give us a DDOS attack? 

133 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_LONG) 

134 

135 # check for most common weak passwords (not meant to be an exhaustive check!) 

136 if password.lower() in ("password", "12345678", "couchers", "couchers1"): 

137 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INSECURE_PASSWORD) 

138 

139 

140def _format_volunteer_link(volunteer, username): 

141 if volunteer.link_type: 

142 return dict(link_type=volunteer.link_type, link_text=volunteer.link_text, link_url=volunteer.link_url) 

143 else: 

144 return dict( 

145 link_type="couchers", 

146 link_text=f"@{username}", 

147 link_url=urls.user_link(username=username), 

148 ) 

149 

150 

151def _volunteer_info_to_pb(volunteer, username): 

152 return account_pb2.GetMyVolunteerInfoRes( 

153 display_name=volunteer.display_name, 

154 display_location=volunteer.display_location, 

155 role=volunteer.role, 

156 started_volunteering=date_to_api(volunteer.started_volunteering), 

157 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None, 

158 show_on_team_page=volunteer.show_on_team_page, 

159 **_format_volunteer_link(volunteer, username), 

160 ) 

161 

162 

163class Account(account_pb2_grpc.AccountServicer): 

164 def GetAccountInfo(self, request, context, session): 

165 user, volunteer = session.execute( 

166 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

167 ).one() 

168 

169 return account_pb2.GetAccountInfoRes( 

170 username=user.username, 

171 email=user.email, 

172 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None, 

173 has_donated=user.has_donated, 

174 phone_verified=user.phone_is_verified, 

175 profile_complete=user.has_completed_profile, 

176 my_home_complete=user.has_completed_my_home, 

177 timezone=user.timezone, 

178 is_superuser=user.is_superuser, 

179 ui_language_preference=user.ui_language_preference, 

180 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

181 is_volunteer=volunteer is not None, 

182 **get_strong_verification_fields(session, user), 

183 ) 

184 

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

186 """ 

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

188 

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

190 """ 

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

192 

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

194 # wrong password 

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

196 

197 abort_on_invalid_password(request.new_password, context) 

198 user.hashed_password = hash_password(request.new_password) 

199 

200 session.commit() 

201 

202 notify( 

203 session, 

204 user_id=user.id, 

205 topic_action="password:change", 

206 ) 

207 

208 return empty_pb2.Empty() 

209 

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

211 """ 

212 Change the user's email address. 

213 

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

215 

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

217 

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

219 """ 

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

221 

222 # check password first 

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

224 # wrong password 

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

226 

227 # not a valid email 

228 if not is_valid_email(request.new_email): 

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

230 

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

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

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

234 

235 user.new_email = request.new_email 

236 user.new_email_token = urlsafe_secure_token() 

237 user.new_email_token_created = now() 

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

239 

240 send_email_changed_confirmation_to_new_email(session, user) 

241 

242 # will still go into old email 

243 notify( 

244 session, 

245 user_id=user.id, 

246 topic_action="email_address:change", 

247 data=notification_data_pb2.EmailAddressChange( 

248 new_email=request.new_email, 

249 ), 

250 ) 

251 

252 # session autocommit 

253 return empty_pb2.Empty() 

254 

255 def ChangeLanguagePreference(self, request, context, session): 

256 # select the user from the db 

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

258 

259 # update the user's preference 

260 user.ui_language_preference = request.ui_language_preference 

261 context.set_cookies(create_lang_cookie(request.ui_language_preference)) 

262 

263 return empty_pb2.Empty() 

264 

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

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

267 

268 form = request.contributor_form 

269 

270 form = ContributorForm( 

271 user=user, 

272 ideas=form.ideas or None, 

273 features=form.features or None, 

274 experience=form.experience or None, 

275 contribute=contributeoption2sql[form.contribute], 

276 contribute_ways=form.contribute_ways, 

277 expertise=form.expertise or None, 

278 ) 

279 

280 session.add(form) 

281 session.flush() 

282 maybe_send_contributor_form_email(session, form) 

283 

284 user.filled_contributor_form = True 

285 

286 return empty_pb2.Empty() 

287 

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

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

290 

291 return account_pb2.GetContributorFormInfoRes( 

292 filled_contributor_form=user.filled_contributor_form, 

293 ) 

294 

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

296 phone = request.phone 

297 # early quick validation 

298 if phone and not is_e164_format(phone): 

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

300 

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

302 if not user.has_donated: 

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

304 

305 if not phone: 

306 user.phone = None 

307 user.phone_verification_verified = None 

308 user.phone_verification_token = None 

309 user.phone_verification_attempts = 0 

310 return empty_pb2.Empty() 

311 

312 if not is_known_operator(phone): 

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

314 

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

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

317 

318 token = sms.generate_random_code() 

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

320 

321 if result == "success": 

322 user.phone = phone 

323 user.phone_verification_verified = None 

324 user.phone_verification_token = token 

325 user.phone_verification_sent = now() 

326 user.phone_verification_attempts = 0 

327 

328 notify( 

329 session, 

330 user_id=user.id, 

331 topic_action="phone_number:change", 

332 data=notification_data_pb2.PhoneNumberChange( 

333 phone=phone, 

334 ), 

335 ) 

336 

337 return empty_pb2.Empty() 

338 

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

340 

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

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

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

344 

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

346 if user.phone_verification_token is None: 

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

348 

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

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

351 

352 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

354 

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

356 user.phone_verification_attempts += 1 

357 session.commit() 

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

359 

360 # Delete verifications from everyone else that has this number 

361 session.execute( 

362 update(User) 

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

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

365 .values( 

366 { 

367 "phone_verification_verified": None, 

368 "phone_verification_attempts": 0, 

369 "phone_verification_token": None, 

370 "phone": None, 

371 } 

372 ) 

373 .execution_options(synchronize_session=False) 

374 ) 

375 

376 user.phone_verification_token = None 

377 user.phone_verification_verified = now() 

378 user.phone_verification_attempts = 0 

379 

380 notify( 

381 session, 

382 user_id=user.id, 

383 topic_action="phone_number:verify", 

384 data=notification_data_pb2.PhoneNumberVerify( 

385 phone=user.phone, 

386 ), 

387 ) 

388 

389 return empty_pb2.Empty() 

390 

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

392 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

394 

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

396 existing_verification = session.execute( 

397 select(StrongVerificationAttempt) 

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

399 .where(StrongVerificationAttempt.is_valid) 

400 ).scalar_one_or_none() 

401 if existing_verification: 

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

403 

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

405 

406 verification_attempt_token = urlsafe_secure_token() 

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

408 reference = b64encode( 

409 simple_encrypt( 

410 "iris_callback", 

411 verification_pb2.VerificationReferencePayload( 

412 verification_attempt_token=verification_attempt_token, 

413 user_id=user.id, 

414 ).SerializeToString(), 

415 ) 

416 ) 

417 response = requests.post( 

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

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

420 json={ 

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

422 "face_verification": False, 

423 "reference": reference, 

424 }, 

425 timeout=10, 

426 ) 

427 

428 if response.status_code != 200: 

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

430 

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

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

433 session.add( 

434 StrongVerificationAttempt( 

435 user_id=user.id, 

436 verification_attempt_token=verification_attempt_token, 

437 iris_session_id=iris_session_id, 

438 iris_token=token, 

439 ) 

440 ) 

441 

442 redirect_params = { 

443 "token": token, 

444 "redirect_url": urls.complete_strong_verification_url( 

445 verification_attempt_token=verification_attempt_token 

446 ), 

447 } 

448 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params) 

449 

450 return account_pb2.InitiateStrongVerificationRes( 

451 verification_attempt_token=verification_attempt_token, 

452 redirect_url=redirect_url, 

453 ) 

454 

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

456 verification_attempt = session.execute( 

457 select(StrongVerificationAttempt) 

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

459 .where(StrongVerificationAttempt.is_visible) 

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

461 ).scalar_one_or_none() 

462 if not verification_attempt: 

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

464 status_to_pb = { 

465 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

466 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

467 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

468 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

469 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

470 } 

471 return account_pb2.GetStrongVerificationAttemptStatusRes( 

472 status=status_to_pb.get( 

473 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

474 ), 

475 ) 

476 

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

478 verification_attempts = ( 

479 session.execute( 

480 select(StrongVerificationAttempt) 

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

482 .where(StrongVerificationAttempt.has_full_data) 

483 ) 

484 .scalars() 

485 .all() 

486 ) 

487 for verification_attempt in verification_attempts: 

488 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

489 verification_attempt.has_full_data = False 

490 verification_attempt.passport_encrypted_data = None 

491 verification_attempt.passport_date_of_birth = None 

492 verification_attempt.passport_sex = None 

493 session.flush() 

494 # double check: 

495 verification_attempts = ( 

496 session.execute( 

497 select(StrongVerificationAttempt) 

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

499 .where(StrongVerificationAttempt.has_full_data) 

500 ) 

501 .scalars() 

502 .all() 

503 ) 

504 assert len(verification_attempts) == 0 

505 

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

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

508 

509 return empty_pb2.Empty() 

510 

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

512 """ 

513 Triggers email with token to confirm deletion 

514 

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

516 """ 

517 if not request.confirm: 

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

519 

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

521 

522 reason = request.reason.strip() 

523 if reason: 

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

525 session.add(reason) 

526 session.flush() 

527 send_account_deletion_report_email(session, reason) 

528 

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

530 

531 notify( 

532 session, 

533 user_id=user.id, 

534 topic_action="account_deletion:start", 

535 data=notification_data_pb2.AccountDeletionStart( 

536 deletion_token=token.token, 

537 ), 

538 ) 

539 session.add(token) 

540 

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

542 

543 return empty_pb2.Empty() 

544 

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

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

547 

548 notes = ( 

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

550 .scalars() 

551 .all() 

552 ) 

553 

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

555 

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

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

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

559 

560 user_sessions = ( 

561 session.execute( 

562 select(UserSession) 

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

564 .where(UserSession.is_valid) 

565 .where(UserSession.is_api_key == False) 

566 .where(UserSession.last_seen <= page_token) 

567 .order_by(UserSession.last_seen.desc()) 

568 .limit(page_size + 1) 

569 ) 

570 .scalars() 

571 .all() 

572 ) 

573 

574 def _active_session_to_pb(user_session): 

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

576 return account_pb2.ActiveSession( 

577 created=Timestamp_from_datetime(user_session.created), 

578 expiry=Timestamp_from_datetime(user_session.expiry), 

579 last_seen=Timestamp_from_datetime(user_session.last_seen), 

580 operating_system=user_agent.os.family, 

581 browser=user_agent.browser.family, 

582 device=user_agent.device.family, 

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

584 is_current_session=user_session.token == context.token, 

585 ) 

586 

587 return account_pb2.ListActiveSessionsRes( 

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

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

590 ) 

591 

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

593 session.execute( 

594 update(UserSession) 

595 .where(UserSession.token != context.token) 

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

597 .where(UserSession.is_valid) 

598 .where(UserSession.is_api_key == False) 

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

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

601 .execution_options(synchronize_session=False) 

602 ) 

603 return empty_pb2.Empty() 

604 

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

606 if not request.confirm: 

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

608 

609 session.execute( 

610 update(UserSession) 

611 .where(UserSession.token != context.token) 

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

613 .where(UserSession.is_valid) 

614 .where(UserSession.is_api_key == False) 

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

616 .execution_options(synchronize_session=False) 

617 ) 

618 return empty_pb2.Empty() 

619 

620 def SetProfilePublicVisibility(self, request, context, session): 

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

622 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] 

623 user.has_modified_public_visibility = True 

624 return empty_pb2.Empty() 

625 

626 def CreateInviteCode(self, request, context, session): 

627 code = generate_invite_code() 

628 session.add(InviteCode(id=code, creator_user_id=context.user_id)) 

629 

630 return account_pb2.CreateInviteCodeRes( 

631 code=code, 

632 url=urls.invite_code_link(code=code), 

633 ) 

634 

635 def DisableInviteCode(self, request, context, session): 

636 invite = session.execute( 

637 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id) 

638 ).scalar_one_or_none() 

639 

640 if not invite: 

641 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_FOUND) 

642 

643 invite.disabled = func.now() 

644 session.commit() 

645 

646 return empty_pb2.Empty() 

647 

648 def ListInviteCodes(self, request, context, session): 

649 results = session.execute( 

650 select( 

651 InviteCode.id, 

652 InviteCode.created, 

653 InviteCode.disabled, 

654 func.count(User.id).label("num_users"), 

655 ) 

656 .outerjoin(User, User.invite_code_id == InviteCode.id) 

657 .where(InviteCode.creator_user_id == context.user_id) 

658 .group_by(InviteCode.id, InviteCode.disabled) 

659 .order_by(func.count(User.id).desc(), InviteCode.disabled) 

660 ).all() 

661 

662 return account_pb2.ListInviteCodesRes( 

663 invite_codes=[ 

664 account_pb2.InviteCodeInfo( 

665 code=code_id, 

666 created=Timestamp_from_datetime(created), 

667 disabled=Timestamp_from_datetime(disabled) if disabled else None, 

668 uses=len_users, 

669 url=urls.invite_code_link(code=code_id), 

670 ) 

671 for code_id, created, disabled, len_users in results 

672 ] 

673 ) 

674 

675 def GetReminders(self, request, context, session): 

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

677 

678 # responding to reqs comes first in desc order of when they were received 

679 pending_host_requests = session.execute( 

680 select(HostRequest.conversation_id, LiteUser) 

681 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id) 

682 .where_users_column_visible(context, HostRequest.surfer_user_id) 

683 .where(HostRequest.host_user_id == context.user_id) 

684 .where(HostRequest.status == HostRequestStatus.pending) 

685 .where(HostRequest.start_time > func.now()) 

686 .order_by(HostRequest.conversation_id.asc()) 

687 ).all() 

688 reminders = [ 

689 account_pb2.Reminder( 

690 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

691 host_request_id=host_request_id, 

692 surfer_user=lite_user_to_pb(lite_user), 

693 ) 

694 ) 

695 for host_request_id, lite_user in pending_host_requests 

696 ] 

697 

698 # references come second, in order of deadline, desc 

699 reminders += [ 

700 account_pb2.Reminder( 

701 write_reference_reminder=account_pb2.WriteReferenceReminder( 

702 host_request_id=host_request_id, 

703 reference_type=reftype2api[reference_type], 

704 other_user=lite_user_to_pb(lite_user), 

705 ) 

706 ) 

707 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context) 

708 ] 

709 

710 if not user.has_completed_profile: 

711 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder())) 

712 

713 if not has_strong_verification(session, user): 

714 reminders.append( 

715 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder()) 

716 ) 

717 

718 return account_pb2.GetRemindersRes(reminders=reminders) 

719 

720 def GetMyVolunteerInfo(self, request, context, session): 

721 user, volunteer = session.execute( 

722 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

723 ).one() 

724 if not volunteer: 

725 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_A_VOLUNTEER) 

726 return _volunteer_info_to_pb(volunteer, user.username) 

727 

728 def UpdateMyVolunteerInfo(self, request, context, session): 

729 user, volunteer = session.execute( 

730 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

731 ).one() 

732 if not volunteer: 

733 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_A_VOLUNTEER) 

734 

735 if request.HasField("display_name"): 

736 volunteer.display_name = request.display_name.value or None 

737 

738 if request.HasField("display_location"): 

739 volunteer.display_location = request.display_location.value or None 

740 

741 if request.HasField("show_on_team_page"): 

742 volunteer.show_on_team_page = request.show_on_team_page.value 

743 

744 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 

745 link_type = request.link_type.value or volunteer.link_type 

746 link_text = request.link_text.value or volunteer.link_text 

747 link_url = request.link_url.value or volunteer.link_url 

748 if link_type == "couchers": 

749 # this is the default 

750 link_type = None 

751 link_text = None 

752 link_url = None 

753 elif link_type == "linkedin": 

754 # this is the username 

755 link_text = link_text 

756 link_url = f"https://www.linkedin.com/in/{link_text}/" 

757 elif link_type == "email": 

758 if not is_valid_email(link_text): 

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

760 link_url = f"mailto:{link_text}" 

761 elif link_type == "website": 

762 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 

763 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_WEBSITE_URL) 

764 else: 

765 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_LINK_TYPE) 

766 volunteer.link_type = link_type 

767 volunteer.link_text = link_text 

768 volunteer.link_url = link_url 

769 

770 session.flush() 

771 

772 return _volunteer_info_to_pb(volunteer, user.username) 

773 

774 

775class Iris(iris_pb2_grpc.IrisServicer): 

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

777 json_data = json.loads(request.data) 

778 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

779 simple_decrypt("iris_callback", b64decode(json_data["session_reference"])) 

780 ) 

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

782 verification_attempt_token = reference_payload.verification_attempt_token 

783 user_id = reference_payload.user_id 

784 

785 verification_attempt = session.execute( 

786 select(StrongVerificationAttempt) 

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

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

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

790 ).scalar_one() 

791 iris_status = json_data["session_state"] 

792 session.add( 

793 StrongVerificationCallbackEvent( 

794 verification_attempt_id=verification_attempt.id, 

795 iris_status=iris_status, 

796 ) 

797 ) 

798 if iris_status == "INITIATED": 

799 # the user opened the session in the app 

800 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

801 elif iris_status == "COMPLETED": 

802 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

803 elif iris_status == "APPROVED": 

804 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

805 session.commit() 

806 # background worker will go and sort this one out 

807 queue_job( 

808 session, 

809 job_type="finalize_strong_verification", 

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

811 priority=8, 

812 ) 

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

814 verification_attempt.status = StrongVerificationAttemptStatus.failed 

815 

816 return httpbody_pb2.HttpBody( 

817 content_type="application/json", 

818 # json.dumps escapes non-ascii characters 

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

820 )