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

309 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-14 11:56 +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.orm import Session 

10from sqlalchemy.sql import func, update 

11from user_agents import parse as user_agents_parse 

12 

13from couchers import urls 

14from couchers.config import config 

15from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME 

16from couchers.context import CouchersContext 

17from couchers.crypto import ( 

18 b64decode, 

19 b64encode, 

20 generate_invite_code, 

21 hash_password, 

22 simple_decrypt, 

23 simple_encrypt, 

24 urlsafe_secure_token, 

25 verify_password, 

26 verify_token, 

27) 

28from couchers.helpers.geoip import geoip_approximate_location 

29from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

30from couchers.jobs.enqueue import queue_job 

31from couchers.materialized_views import LiteUser 

32from couchers.metrics import ( 

33 account_deletion_initiations_counter, 

34 strong_verification_data_deletions_counter, 

35 strong_verification_initiations_counter, 

36) 

37from couchers.models import ( 

38 AccountDeletionReason, 

39 AccountDeletionToken, 

40 ContributeOption, 

41 ContributorForm, 

42 HostRequest, 

43 HostRequestStatus, 

44 InviteCode, 

45 ModNote, 

46 ProfilePublicVisibility, 

47 StrongVerificationAttempt, 

48 StrongVerificationAttemptStatus, 

49 StrongVerificationCallbackEvent, 

50 User, 

51 UserSession, 

52 Volunteer, 

53) 

54from couchers.notifications.notify import notify 

55from couchers.phone import sms 

56from couchers.phone.check import is_e164_format, is_known_operator 

57from couchers.servicers.api import lite_user_to_pb 

58from couchers.servicers.references import get_pending_references_to_write, reftype2api 

59from couchers.sql import couchers_select as select 

60from couchers.tasks import ( 

61 maybe_send_contributor_form_email, 

62 send_account_deletion_report_email, 

63 send_email_changed_confirmation_to_new_email, 

64) 

65from couchers.utils import ( 

66 Timestamp_from_datetime, 

67 create_lang_cookie, 

68 date_to_api, 

69 dt_from_page_token, 

70 dt_to_page_token, 

71 is_valid_email, 

72 now, 

73 to_aware_datetime, 

74) 

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

76from proto.google.api import httpbody_pb2 

77from proto.internal import jobs_pb2, verification_pb2 

78 

79logger = logging.getLogger(__name__) 

80logger.setLevel(logging.DEBUG) 

81 

82contributeoption2sql = { 

83 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

84 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

85 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

86 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

87} 

88 

89contributeoption2api = { 

90 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

91 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

92 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

93 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

94} 

95 

96profilepublicitysetting2sql = { 

97 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

98 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

99 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

100 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

101 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

102 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

103} 

104 

105profilepublicitysetting2api = { 

106 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

107 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

108 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

109 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

110 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

111 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

112} 

113 

114MAX_PAGINATION_LENGTH = 50 

115 

116 

117def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote: 

118 return account_pb2.ModNote( 

119 note_id=note.id, 

120 note_content=note.note_content, 

121 created=Timestamp_from_datetime(note.created), 

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

123 ) 

124 

125 

126def abort_on_invalid_password(password: str, context: CouchersContext) -> None: 

127 """ 

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

129 """ 

130 if len(password) < 8: 

131 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short") 

132 

133 if len(password) > 256: 

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

135 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long") 

136 

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

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

139 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password") 

140 

141 

142def _format_volunteer_link(volunteer: Volunteer, username: str) -> dict[str, str]: 

143 if volunteer.link_type: 

144 return dict( 

145 link_type=volunteer.link_type, 

146 link_text=volunteer.link_text, 

147 link_url=volunteer.link_url, 

148 ) 

149 else: 

150 return dict( 

151 link_type="couchers", 

152 link_text=f"@{username}", 

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

154 ) 

155 

156 

157def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes: 

158 return account_pb2.GetMyVolunteerInfoRes( 

159 display_name=volunteer.display_name, 

160 display_location=volunteer.display_location, 

161 role=volunteer.role, 

162 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

164 show_on_team_page=volunteer.show_on_team_page, 

165 **_format_volunteer_link(volunteer, username), 

166 ) 

167 

168 

169class Account(account_pb2_grpc.AccountServicer): 

170 def GetAccountInfo( 

171 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

172 ) -> account_pb2.GetAccountInfoRes: 

173 user, volunteer = session.execute( 

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

175 ).one() 

176 

177 return account_pb2.GetAccountInfoRes( 

178 username=user.username, 

179 email=user.email, 

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

181 has_donated=user.has_donated, 

182 phone_verified=user.phone_is_verified, 

183 profile_complete=user.has_completed_profile, 

184 my_home_complete=user.has_completed_my_home, 

185 timezone=user.timezone, 

186 is_superuser=user.is_superuser, 

187 ui_language_preference=user.ui_language_preference, 

188 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

189 is_volunteer=volunteer is not None, 

190 **get_strong_verification_fields(session, user), 

191 ) 

192 

193 def ChangePasswordV2( 

194 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session 

195 ) -> empty_pb2.Empty: 

196 """ 

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

198 

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

200 """ 

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

202 

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

204 # wrong password 

205 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password") 

206 

207 abort_on_invalid_password(request.new_password, context) 

208 user.hashed_password = hash_password(request.new_password) 

209 

210 session.commit() 

211 

212 notify( 

213 session, 

214 user_id=user.id, 

215 topic_action="password:change", 

216 ) 

217 

218 return empty_pb2.Empty() 

219 

220 def ChangeEmailV2( 

221 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session 

222 ) -> empty_pb2.Empty: 

223 """ 

224 Change the user's email address. 

225 

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

227 

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

229 

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

231 """ 

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

233 

234 # check password first 

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

236 # wrong password 

237 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password") 

238 

239 # not a valid email 

240 if not is_valid_email(request.new_email): 

241 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

242 

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

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

245 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

246 

247 user.new_email = request.new_email 

248 user.new_email_token = urlsafe_secure_token() 

249 user.new_email_token_created = now() 

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

251 

252 send_email_changed_confirmation_to_new_email(session, user) 

253 

254 # will still go into old email 

255 notify( 

256 session, 

257 user_id=user.id, 

258 topic_action="email_address:change", 

259 data=notification_data_pb2.EmailAddressChange( 

260 new_email=request.new_email, 

261 ), 

262 ) 

263 

264 # session autocommit 

265 return empty_pb2.Empty() 

266 

267 def ChangeLanguagePreference( 

268 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session 

269 ) -> empty_pb2.Empty: 

270 # select the user from the db 

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

272 

273 # update the user's preference 

274 user.ui_language_preference = request.ui_language_preference 

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

276 

277 return empty_pb2.Empty() 

278 

279 def FillContributorForm( 

280 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session 

281 ) -> empty_pb2.Empty: 

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

283 

284 form = request.contributor_form 

285 

286 form = ContributorForm( 

287 user=user, 

288 ideas=form.ideas or None, 

289 features=form.features or None, 

290 experience=form.experience or None, 

291 contribute=contributeoption2sql[form.contribute], 

292 contribute_ways=form.contribute_ways, 

293 expertise=form.expertise or None, 

294 ) 

295 

296 session.add(form) 

297 session.flush() 

298 maybe_send_contributor_form_email(session, form) 

299 

300 user.filled_contributor_form = True 

301 

302 return empty_pb2.Empty() 

303 

304 def GetContributorFormInfo( 

305 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

306 ) -> account_pb2.GetContributorFormInfoRes: 

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

308 

309 return account_pb2.GetContributorFormInfoRes( 

310 filled_contributor_form=user.filled_contributor_form, 

311 ) 

312 

313 def ChangePhone( 

314 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session 

315 ) -> empty_pb2.Empty: 

316 phone = request.phone 

317 # early quick validation 

318 if phone and not is_e164_format(phone): 

319 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone") 

320 

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

322 if not user.has_donated: 

323 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated") 

324 

325 if not phone: 

326 user.phone = None 

327 user.phone_verification_verified = None 

328 user.phone_verification_token = None 

329 user.phone_verification_attempts = 0 

330 return empty_pb2.Empty() 

331 

332 if not is_known_operator(phone): 

333 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number") 

334 

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

336 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early") 

337 

338 token = sms.generate_random_code() 

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

340 

341 if result == "success": 

342 user.phone = phone 

343 user.phone_verification_verified = None 

344 user.phone_verification_token = token 

345 user.phone_verification_sent = now() 

346 user.phone_verification_attempts = 0 

347 

348 notify( 

349 session, 

350 user_id=user.id, 

351 topic_action="phone_number:change", 

352 data=notification_data_pb2.PhoneNumberChange( 

353 phone=phone, 

354 ), 

355 ) 

356 

357 return empty_pb2.Empty() 

358 

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

360 

361 def VerifyPhone( 

362 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session 

363 ) -> empty_pb2.Empty: 

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

365 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code") 

366 

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

368 if user.phone_verification_token is None: 

369 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification") 

370 

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

372 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification") 

373 

374 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

375 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts") 

376 

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

378 user.phone_verification_attempts += 1 

379 session.commit() 

380 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code") 

381 

382 # Delete verifications from everyone else that has this number 

383 session.execute( 

384 update(User) 

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

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

387 .values( 

388 { 

389 "phone_verification_verified": None, 

390 "phone_verification_attempts": 0, 

391 "phone_verification_token": None, 

392 "phone": None, 

393 } 

394 ) 

395 .execution_options(synchronize_session=False) 

396 ) 

397 

398 user.phone_verification_token = None 

399 user.phone_verification_verified = now() 

400 user.phone_verification_attempts = 0 

401 

402 notify( 

403 session, 

404 user_id=user.id, 

405 topic_action="phone_number:verify", 

406 data=notification_data_pb2.PhoneNumberVerify( 

407 phone=user.phone, 

408 ), 

409 ) 

410 

411 return empty_pb2.Empty() 

412 

413 def InitiateStrongVerification( 

414 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

415 ) -> account_pb2.InitiateStrongVerificationRes: 

416 if not config["ENABLE_STRONG_VERIFICATION"]: 

417 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled") 

418 

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

420 existing_verification: StrongVerificationAttempt = session.execute( 

421 select(StrongVerificationAttempt) 

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

423 .where(StrongVerificationAttempt.is_valid) 

424 ).scalar_one_or_none() 

425 if existing_verification: 

426 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified") 

427 

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

429 

430 verification_attempt_token = urlsafe_secure_token() 

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

432 reference = b64encode( 

433 simple_encrypt( 

434 "iris_callback", 

435 verification_pb2.VerificationReferencePayload( 

436 verification_attempt_token=verification_attempt_token, 

437 user_id=user.id, 

438 ).SerializeToString(), 

439 ) 

440 ) 

441 response = requests.post( 

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

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

444 json={ 

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

446 "face_verification": False, 

447 "passport_only": True, 

448 "reference": reference, 

449 }, 

450 timeout=10, 

451 verify="/etc/ssl/certs/ca-certificates.crt", 

452 ) 

453 

454 if response.status_code != 200: 

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

456 

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

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

459 session.add( 

460 StrongVerificationAttempt( 

461 user_id=user.id, 

462 verification_attempt_token=verification_attempt_token, 

463 iris_session_id=iris_session_id, 

464 iris_token=token, 

465 ) 

466 ) 

467 

468 redirect_params = { 

469 "token": token, 

470 "redirect_url": urls.complete_strong_verification_url( 

471 verification_attempt_token=verification_attempt_token 

472 ), 

473 } 

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

475 

476 return account_pb2.InitiateStrongVerificationRes( 

477 verification_attempt_token=verification_attempt_token, 

478 redirect_url=redirect_url, 

479 ) 

480 

481 def GetStrongVerificationAttemptStatus( 

482 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session 

483 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

484 verification_attempt = session.execute( 

485 select(StrongVerificationAttempt) 

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

487 .where(StrongVerificationAttempt.is_visible) 

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

489 ).scalar_one_or_none() 

490 if not verification_attempt: 

491 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found") 

492 status_to_pb = { 

493 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

494 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

495 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

496 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

497 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

498 } 

499 return account_pb2.GetStrongVerificationAttemptStatusRes( 

500 status=status_to_pb.get( 

501 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

502 ), 

503 ) 

504 

505 def DeleteStrongVerificationData( 

506 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

507 ) -> empty_pb2.Empty: 

508 verification_attempts = ( 

509 session.execute( 

510 select(StrongVerificationAttempt) 

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

512 .where(StrongVerificationAttempt.has_full_data) 

513 ) 

514 .scalars() 

515 .all() 

516 ) 

517 for verification_attempt in verification_attempts: 

518 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

519 verification_attempt.has_full_data = False 

520 verification_attempt.passport_encrypted_data = None 

521 verification_attempt.passport_date_of_birth = None 

522 verification_attempt.passport_sex = None 

523 session.flush() 

524 # double check: 

525 verification_attempts = ( 

526 session.execute( 

527 select(StrongVerificationAttempt) 

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

529 .where(StrongVerificationAttempt.has_full_data) 

530 ) 

531 .scalars() 

532 .all() 

533 ) 

534 assert len(verification_attempts) == 0 

535 

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

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

538 

539 return empty_pb2.Empty() 

540 

541 def DeleteAccount( 

542 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session 

543 ) -> empty_pb2.Empty: 

544 """ 

545 Triggers email with token to confirm deletion 

546 

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

548 """ 

549 if not request.confirm: 

550 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete") 

551 

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

553 

554 reason = request.reason.strip() 

555 if reason: 

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

557 session.add(reason) 

558 session.flush() 

559 send_account_deletion_report_email(session, reason) 

560 

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

562 

563 notify( 

564 session, 

565 user_id=user.id, 

566 topic_action="account_deletion:start", 

567 data=notification_data_pb2.AccountDeletionStart( 

568 deletion_token=token.token, 

569 ), 

570 ) 

571 session.add(token) 

572 

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

574 

575 return empty_pb2.Empty() 

576 

577 def ListModNotes( 

578 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

579 ) -> account_pb2.ListModNotesRes: 

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

581 

582 notes = ( 

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

584 .scalars() 

585 .all() 

586 ) 

587 

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

589 

590 def ListActiveSessions( 

591 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session 

592 ) -> account_pb2.ListActiveSessionsRes: 

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

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

595 

596 user_sessions = ( 

597 session.execute( 

598 select(UserSession) 

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

600 .where(UserSession.is_valid) 

601 .where(UserSession.is_api_key == False) 

602 .where(UserSession.last_seen <= page_token) 

603 .order_by(UserSession.last_seen.desc()) 

604 .limit(page_size + 1) 

605 ) 

606 .scalars() 

607 .all() 

608 ) 

609 

610 def _active_session_to_pb(user_session): 

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

612 return account_pb2.ActiveSession( 

613 created=Timestamp_from_datetime(user_session.created), 

614 expiry=Timestamp_from_datetime(user_session.expiry), 

615 last_seen=Timestamp_from_datetime(user_session.last_seen), 

616 operating_system=user_agent.os.family, 

617 browser=user_agent.browser.family, 

618 device=user_agent.device.family, 

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

620 is_current_session=user_session.token == context.token, 

621 ) 

622 

623 return account_pb2.ListActiveSessionsRes( 

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

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

626 ) 

627 

628 def LogOutSession( 

629 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session 

630 ) -> empty_pb2.Empty: 

631 session.execute( 

632 update(UserSession) 

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

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

635 .where(UserSession.is_valid) 

636 .where(UserSession.is_api_key == False) 

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

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

639 .execution_options(synchronize_session=False) 

640 ) 

641 return empty_pb2.Empty() 

642 

643 def LogOutOtherSessions( 

644 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session 

645 ) -> empty_pb2.Empty: 

646 if not request.confirm: 

647 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions") 

648 

649 session.execute( 

650 update(UserSession) 

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

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

653 .where(UserSession.is_valid) 

654 .where(UserSession.is_api_key == False) 

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

656 .execution_options(synchronize_session=False) 

657 ) 

658 return empty_pb2.Empty() 

659 

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

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

662 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] 

663 user.has_modified_public_visibility = True 

664 return empty_pb2.Empty() 

665 

666 def CreateInviteCode( 

667 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

668 ) -> account_pb2.CreateInviteCodeRes: 

669 code = generate_invite_code() 

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

671 

672 return account_pb2.CreateInviteCodeRes( 

673 code=code, 

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

675 ) 

676 

677 def DisableInviteCode( 

678 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session 

679 ) -> empty_pb2.Empty: 

680 invite = session.execute( 

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

682 ).scalar_one_or_none() 

683 

684 if not invite: 

685 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found") 

686 

687 invite.disabled = func.now() 

688 session.commit() 

689 

690 return empty_pb2.Empty() 

691 

692 def ListInviteCodes( 

693 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

694 ) -> account_pb2.ListInviteCodesRes: 

695 results = session.execute( 

696 select( 

697 InviteCode.id, 

698 InviteCode.created, 

699 InviteCode.disabled, 

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

701 ) 

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

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

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

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

706 ).all() 

707 

708 return account_pb2.ListInviteCodesRes( 

709 invite_codes=[ 

710 account_pb2.InviteCodeInfo( 

711 code=code_id, 

712 created=Timestamp_from_datetime(created), 

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

714 uses=len_users, 

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

716 ) 

717 for code_id, created, disabled, len_users in results 

718 ] 

719 ) 

720 

721 def GetReminders( 

722 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

723 ) -> account_pb2.GetRemindersRes: 

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

725 

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

727 pending_host_requests = session.execute( 

728 select(HostRequest.conversation_id, LiteUser) 

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

730 .where_users_column_visible(context, HostRequest.surfer_user_id) 

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

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

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

734 .order_by(HostRequest.conversation_id.asc()) 

735 ).all() 

736 reminders = [ 

737 account_pb2.Reminder( 

738 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

739 host_request_id=host_request_id, 

740 surfer_user=lite_user_to_pb(lite_user), 

741 ) 

742 ) 

743 for host_request_id, lite_user in pending_host_requests 

744 ] 

745 

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

747 reminders += [ 

748 account_pb2.Reminder( 

749 write_reference_reminder=account_pb2.WriteReferenceReminder( 

750 host_request_id=host_request_id, 

751 reference_type=reftype2api[reference_type], 

752 other_user=lite_user_to_pb(lite_user), 

753 ) 

754 ) 

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

756 ] 

757 

758 if not user.has_completed_profile: 

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

760 

761 if not has_strong_verification(session, user): 

762 reminders.append( 

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

764 ) 

765 

766 return account_pb2.GetRemindersRes(reminders=reminders) 

767 

768 def GetMyVolunteerInfo( 

769 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

770 ) -> account_pb2.GetMyVolunteerInfoRes: 

771 user, volunteer = session.execute( 

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

773 ).one() 

774 if not volunteer: 

775 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer") 

776 return _volunteer_info_to_pb(volunteer, user.username) 

777 

778 def UpdateMyVolunteerInfo( 

779 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session 

780 ) -> account_pb2.GetMyVolunteerInfoRes: 

781 user, volunteer = session.execute( 

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

783 ).one() 

784 if not volunteer: 

785 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer") 

786 

787 if request.HasField("display_name"): 

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

789 

790 if request.HasField("display_location"): 

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

792 

793 if request.HasField("show_on_team_page"): 

794 volunteer.show_on_team_page = request.show_on_team_page.value 

795 

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

797 link_type = request.link_type.value or volunteer.link_type 

798 link_text = request.link_text.value or volunteer.link_text 

799 link_url = request.link_url.value or volunteer.link_url 

800 if link_type == "couchers": 

801 # this is the default 

802 link_type = None 

803 link_text = None 

804 link_url = None 

805 elif link_type == "linkedin": 

806 # this is the username 

807 link_text = link_text 

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

809 elif link_type == "email": 

810 if not is_valid_email(link_text): 

811 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

812 link_url = f"mailto:{link_text}" 

813 elif link_type == "website": 

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

815 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url") 

816 else: 

817 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type") 

818 volunteer.link_type = link_type 

819 volunteer.link_text = link_text 

820 volunteer.link_url = link_url 

821 

822 session.flush() 

823 

824 return _volunteer_info_to_pb(volunteer, user.username) 

825 

826 

827class Iris(iris_pb2_grpc.IrisServicer): 

828 def Webhook( 

829 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session 

830 ) -> httpbody_pb2.HttpBody: 

831 json_data = json.loads(request.data) 

832 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

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

834 ) 

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

836 verification_attempt_token = reference_payload.verification_attempt_token 

837 user_id = reference_payload.user_id 

838 

839 verification_attempt = session.execute( 

840 select(StrongVerificationAttempt) 

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

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

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

844 ).scalar_one() 

845 iris_status = json_data["session_state"] 

846 session.add( 

847 StrongVerificationCallbackEvent( 

848 verification_attempt_id=verification_attempt.id, 

849 iris_status=iris_status, 

850 ) 

851 ) 

852 if iris_status == "INITIATED": 

853 # the user opened the session in the app 

854 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

855 elif iris_status == "COMPLETED": 

856 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

857 elif iris_status == "APPROVED": 

858 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

859 session.commit() 

860 # background worker will go and sort this one out 

861 queue_job( 

862 session, 

863 job_type="finalize_strong_verification", 

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

865 priority=8, 

866 ) 

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

868 verification_attempt.status = StrongVerificationAttemptStatus.failed 

869 

870 return httpbody_pb2.HttpBody( 

871 content_type="application/json", 

872 # json.dumps escapes non-ascii characters 

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

874 )