Coverage for src/couchers/servicers/account.py: 94%
310 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-15 14:48 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-15 14:48 +0000
1import json
2import logging
3from datetime import timedelta
4from urllib.parse import urlencode
6import grpc
7import requests
8from google.protobuf import empty_pb2
9from sqlalchemy.orm import Session
10from sqlalchemy.sql import func, update
11from user_agents import parse as user_agents_parse
13from couchers import urls
14from couchers.config import config
15from couchers.constants import DONATION_DRIVE_START, 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.experimentation import check_gate
29from couchers.helpers.geoip import geoip_approximate_location
30from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification
31from couchers.jobs.enqueue import queue_job
32from couchers.materialized_views import LiteUser
33from couchers.metrics import (
34 account_deletion_initiations_counter,
35 strong_verification_data_deletions_counter,
36 strong_verification_initiations_counter,
37)
38from couchers.models import (
39 AccountDeletionReason,
40 AccountDeletionToken,
41 ContributeOption,
42 ContributorForm,
43 HostRequest,
44 HostRequestStatus,
45 InviteCode,
46 ModNote,
47 ProfilePublicVisibility,
48 StrongVerificationAttempt,
49 StrongVerificationAttemptStatus,
50 StrongVerificationCallbackEvent,
51 User,
52 UserSession,
53 Volunteer,
54)
55from couchers.notifications.notify import notify
56from couchers.phone import sms
57from couchers.phone.check import is_e164_format, is_known_operator
58from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
59from couchers.proto.google.api import httpbody_pb2
60from couchers.proto.internal import jobs_pb2, verification_pb2
61from couchers.servicers.api import lite_user_to_pb
62from couchers.servicers.public import format_volunteer_link
63from couchers.servicers.references import get_pending_references_to_write, reftype2api
64from couchers.sql import couchers_select as select
65from couchers.tasks import (
66 maybe_send_contributor_form_email,
67 send_account_deletion_report_email,
68 send_email_changed_confirmation_to_new_email,
69)
70from couchers.utils import (
71 Timestamp_from_datetime,
72 create_lang_cookie,
73 date_to_api,
74 dt_from_page_token,
75 dt_to_page_token,
76 is_valid_email,
77 now,
78 to_aware_datetime,
79)
81logger = logging.getLogger(__name__)
82logger.setLevel(logging.DEBUG)
84contributeoption2sql = {
85 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
86 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
87 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
88 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
89}
91contributeoption2api = {
92 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
93 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
94 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
95 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
96}
98profilepublicitysetting2sql = {
99 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
100 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
101 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
102 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
103 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
104 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
105}
107profilepublicitysetting2api = {
108 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
109 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
110 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
111 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
112 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
113 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
114}
116MAX_PAGINATION_LENGTH = 50
119def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote:
120 return account_pb2.ModNote(
121 note_id=note.id,
122 note_content=note.note_content,
123 created=Timestamp_from_datetime(note.created),
124 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
125 )
128def abort_on_invalid_password(password: str, context: CouchersContext) -> None:
129 """
130 Internal utility function: given a password, aborts if password is unforgivably insecure
131 """
132 if len(password) < 8:
133 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short")
135 if len(password) > 256:
136 # Hey, what are you trying to do? Give us a DDOS attack?
137 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long")
139 # check for the most common weak passwords (not meant to be an exhaustive check!)
140 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
141 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password")
144def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes:
145 return account_pb2.GetMyVolunteerInfoRes(
146 display_name=volunteer.display_name,
147 display_location=volunteer.display_location,
148 role=volunteer.role,
149 started_volunteering=date_to_api(volunteer.started_volunteering),
150 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
151 show_on_team_page=volunteer.show_on_team_page,
152 **format_volunteer_link(volunteer, username),
153 )
156class Account(account_pb2_grpc.AccountServicer):
157 def GetAccountInfo(
158 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
159 ) -> account_pb2.GetAccountInfoRes:
160 user, volunteer = session.execute(
161 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
162 ).one()
164 # Test experimentation integration - check if user is in the test gate
165 # Create 'test_statsig_integration' in Statsig console to test
166 test_gate = check_gate(context, "test_statsig_integration")
167 logger.info(f"Experimentation gate 'test_statsig_integration' for user {user.id}: {test_gate}")
169 should_show_donation_banner = DONATION_DRIVE_START is not None and (
170 user.last_donated is None or user.last_donated < DONATION_DRIVE_START
171 )
173 return account_pb2.GetAccountInfoRes(
174 username=user.username,
175 email=user.email,
176 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
177 has_donated=user.last_donated is not None,
178 phone_verified=user.phone_is_verified,
179 profile_complete=user.has_completed_profile,
180 my_home_complete=user.has_completed_my_home,
181 timezone=user.timezone,
182 is_superuser=user.is_superuser,
183 ui_language_preference=user.ui_language_preference,
184 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
185 is_volunteer=volunteer is not None,
186 should_show_donation_banner=should_show_donation_banner,
187 **get_strong_verification_fields(session, user),
188 )
190 def ChangePasswordV2(
191 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session
192 ) -> empty_pb2.Empty:
193 """
194 Changes the user's password. They have to confirm their old password just in case.
196 If they didn't have an old password previously, then we don't check that.
197 """
198 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
200 if not verify_password(user.hashed_password, request.old_password):
201 # wrong password
202 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
204 abort_on_invalid_password(request.new_password, context)
205 user.hashed_password = hash_password(request.new_password)
207 session.commit()
209 notify(
210 session,
211 user_id=user.id,
212 topic_action="password:change",
213 key="",
214 )
216 return empty_pb2.Empty()
218 def ChangeEmailV2(
219 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session
220 ) -> empty_pb2.Empty:
221 """
222 Change the user's email address.
224 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
226 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
228 In all confirmation emails, the user must click on the confirmation link.
229 """
230 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
232 # check password first
233 if not verify_password(user.hashed_password, request.password):
234 # wrong password
235 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
237 # not a valid email
238 if not is_valid_email(request.new_email):
239 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
241 # email already in use (possibly by this user)
242 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
243 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
245 user.new_email = request.new_email
246 user.new_email_token = urlsafe_secure_token()
247 user.new_email_token_created = now()
248 user.new_email_token_expiry = now() + timedelta(hours=2)
250 send_email_changed_confirmation_to_new_email(session, user)
252 # will still go into old email
253 notify(
254 session,
255 user_id=user.id,
256 topic_action="email_address:change",
257 key="",
258 data=notification_data_pb2.EmailAddressChange(
259 new_email=request.new_email,
260 ),
261 )
263 # session autocommit
264 return empty_pb2.Empty()
266 def ChangeLanguagePreference(
267 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session
268 ) -> empty_pb2.Empty:
269 # select the user from the db
270 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
272 # update the user's preference
273 user.ui_language_preference = request.ui_language_preference
274 context.set_cookies(create_lang_cookie(request.ui_language_preference))
276 return empty_pb2.Empty()
278 def FillContributorForm(
279 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session
280 ) -> empty_pb2.Empty:
281 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
283 form = request.contributor_form
285 form = ContributorForm(
286 user=user,
287 ideas=form.ideas or None,
288 features=form.features or None,
289 experience=form.experience or None,
290 contribute=contributeoption2sql[form.contribute],
291 contribute_ways=form.contribute_ways,
292 expertise=form.expertise or None,
293 )
295 session.add(form)
296 session.flush()
297 maybe_send_contributor_form_email(session, form)
299 user.filled_contributor_form = True
301 return empty_pb2.Empty()
303 def GetContributorFormInfo(
304 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
305 ) -> account_pb2.GetContributorFormInfoRes:
306 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
308 return account_pb2.GetContributorFormInfoRes(
309 filled_contributor_form=user.filled_contributor_form,
310 )
312 def ChangePhone(
313 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session
314 ) -> empty_pb2.Empty:
315 phone = request.phone
316 # early quick validation
317 if phone and not is_e164_format(phone):
318 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone")
320 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
321 if user.last_donated is None:
322 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated")
324 if not phone:
325 user.phone = None
326 user.phone_verification_verified = None
327 user.phone_verification_token = None
328 user.phone_verification_attempts = 0
329 return empty_pb2.Empty()
331 if not is_known_operator(phone):
332 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
334 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
335 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
337 token = sms.generate_random_code()
338 result = sms.send_sms(phone, sms.format_message(token))
340 if result == "success":
341 user.phone = phone
342 user.phone_verification_verified = None
343 user.phone_verification_token = token
344 user.phone_verification_sent = now()
345 user.phone_verification_attempts = 0
347 notify(
348 session,
349 user_id=user.id,
350 topic_action="phone_number:change",
351 key="",
352 data=notification_data_pb2.PhoneNumberChange(
353 phone=phone,
354 ),
355 )
357 return empty_pb2.Empty()
359 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
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")
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")
371 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME:
372 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
374 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
375 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts")
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")
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 )
398 user.phone_verification_token = None
399 user.phone_verification_verified = now()
400 user.phone_verification_attempts = 0
402 notify(
403 session,
404 user_id=user.id,
405 topic_action="phone_number:verify",
406 key="",
407 data=notification_data_pb2.PhoneNumberVerify(
408 phone=user.phone,
409 ),
410 )
412 return empty_pb2.Empty()
414 def InitiateStrongVerification(
415 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
416 ) -> account_pb2.InitiateStrongVerificationRes:
417 if not config["ENABLE_STRONG_VERIFICATION"]:
418 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled")
420 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
421 existing_verification: StrongVerificationAttempt = session.execute(
422 select(StrongVerificationAttempt)
423 .where(StrongVerificationAttempt.user_id == user.id)
424 .where(StrongVerificationAttempt.is_valid)
425 ).scalar_one_or_none()
426 if existing_verification:
427 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified")
429 strong_verification_initiations_counter.labels(user.gender).inc()
431 verification_attempt_token = urlsafe_secure_token()
432 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
433 reference = b64encode(
434 simple_encrypt(
435 "iris_callback",
436 verification_pb2.VerificationReferencePayload(
437 verification_attempt_token=verification_attempt_token,
438 user_id=user.id,
439 ).SerializeToString(),
440 )
441 )
442 response = requests.post(
443 "https://passportreader.app/api/v1/session.create",
444 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
445 json={
446 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
447 "face_verification": False,
448 "passport_only": True,
449 "reference": reference,
450 },
451 timeout=10,
452 verify="/etc/ssl/certs/ca-certificates.crt",
453 )
455 if response.status_code != 200:
456 raise Exception(f"Iris didn't return 200: {response.text}")
458 iris_session_id = response.json()["id"]
459 token = response.json()["token"]
460 session.add(
461 StrongVerificationAttempt(
462 user_id=user.id,
463 verification_attempt_token=verification_attempt_token,
464 iris_session_id=iris_session_id,
465 iris_token=token,
466 )
467 )
469 redirect_params = {
470 "token": token,
471 "redirect_url": urls.complete_strong_verification_url(
472 verification_attempt_token=verification_attempt_token
473 ),
474 }
475 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
477 return account_pb2.InitiateStrongVerificationRes(
478 verification_attempt_token=verification_attempt_token,
479 redirect_url=redirect_url,
480 )
482 def GetStrongVerificationAttemptStatus(
483 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session
484 ) -> account_pb2.GetStrongVerificationAttemptStatusRes:
485 verification_attempt = session.execute(
486 select(StrongVerificationAttempt)
487 .where(StrongVerificationAttempt.user_id == context.user_id)
488 .where(StrongVerificationAttempt.is_visible)
489 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
490 ).scalar_one_or_none()
491 if not verification_attempt:
492 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found")
493 status_to_pb = {
494 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
495 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
496 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
497 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
498 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
499 }
500 return account_pb2.GetStrongVerificationAttemptStatusRes(
501 status=status_to_pb.get(
502 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
503 ),
504 )
506 def DeleteStrongVerificationData(
507 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
508 ) -> empty_pb2.Empty:
509 verification_attempts = (
510 session.execute(
511 select(StrongVerificationAttempt)
512 .where(StrongVerificationAttempt.user_id == context.user_id)
513 .where(StrongVerificationAttempt.has_full_data)
514 )
515 .scalars()
516 .all()
517 )
518 for verification_attempt in verification_attempts:
519 verification_attempt.status = StrongVerificationAttemptStatus.deleted
520 verification_attempt.has_full_data = False
521 verification_attempt.passport_encrypted_data = None
522 verification_attempt.passport_date_of_birth = None
523 verification_attempt.passport_sex = None
524 session.flush()
525 # double check:
526 verification_attempts = (
527 session.execute(
528 select(StrongVerificationAttempt)
529 .where(StrongVerificationAttempt.user_id == context.user_id)
530 .where(StrongVerificationAttempt.has_full_data)
531 )
532 .scalars()
533 .all()
534 )
535 assert len(verification_attempts) == 0
537 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
538 strong_verification_data_deletions_counter.labels(user.gender).inc()
540 return empty_pb2.Empty()
542 def DeleteAccount(
543 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session
544 ) -> empty_pb2.Empty:
545 """
546 Triggers email with token to confirm deletion
548 Frontend should confirm via unique string (i.e. username) before this is called
549 """
550 if not request.confirm:
551 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete")
553 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
555 reason = request.reason.strip()
556 if reason:
557 reason = AccountDeletionReason(user_id=user.id, reason=reason)
558 session.add(reason)
559 session.flush()
560 send_account_deletion_report_email(session, reason)
562 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
564 notify(
565 session,
566 user_id=user.id,
567 topic_action="account_deletion:start",
568 key="",
569 data=notification_data_pb2.AccountDeletionStart(
570 deletion_token=token.token,
571 ),
572 )
573 session.add(token)
575 account_deletion_initiations_counter.labels(user.gender).inc()
577 return empty_pb2.Empty()
579 def ListModNotes(
580 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
581 ) -> account_pb2.ListModNotesRes:
582 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
584 notes = (
585 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
586 .scalars()
587 .all()
588 )
590 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
592 def ListActiveSessions(
593 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session
594 ) -> account_pb2.ListActiveSessionsRes:
595 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
596 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
598 user_sessions = (
599 session.execute(
600 select(UserSession)
601 .where(UserSession.user_id == context.user_id)
602 .where(UserSession.is_valid)
603 .where(UserSession.is_api_key == False)
604 .where(UserSession.last_seen <= page_token)
605 .order_by(UserSession.last_seen.desc())
606 .limit(page_size + 1)
607 )
608 .scalars()
609 .all()
610 )
612 def _active_session_to_pb(user_session):
613 user_agent = user_agents_parse(user_session.user_agent or "")
614 return account_pb2.ActiveSession(
615 created=Timestamp_from_datetime(user_session.created),
616 expiry=Timestamp_from_datetime(user_session.expiry),
617 last_seen=Timestamp_from_datetime(user_session.last_seen),
618 operating_system=user_agent.os.family,
619 browser=user_agent.browser.family,
620 device=user_agent.device.family,
621 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
622 is_current_session=user_session.token == context.token,
623 )
625 return account_pb2.ListActiveSessionsRes(
626 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
627 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
628 )
630 def LogOutSession(
631 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session
632 ) -> empty_pb2.Empty:
633 session.execute(
634 update(UserSession)
635 .where(UserSession.token != context.token)
636 .where(UserSession.user_id == context.user_id)
637 .where(UserSession.is_valid)
638 .where(UserSession.is_api_key == False)
639 .where(UserSession.created == to_aware_datetime(request.created))
640 .values(expiry=func.now())
641 .execution_options(synchronize_session=False)
642 )
643 return empty_pb2.Empty()
645 def LogOutOtherSessions(
646 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session
647 ) -> empty_pb2.Empty:
648 if not request.confirm:
649 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions")
651 session.execute(
652 update(UserSession)
653 .where(UserSession.token != context.token)
654 .where(UserSession.user_id == context.user_id)
655 .where(UserSession.is_valid)
656 .where(UserSession.is_api_key == False)
657 .values(expiry=func.now())
658 .execution_options(synchronize_session=False)
659 )
660 return empty_pb2.Empty()
662 def SetProfilePublicVisibility(self, request, context, session):
663 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
664 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility]
665 user.has_modified_public_visibility = True
666 return empty_pb2.Empty()
668 def CreateInviteCode(
669 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
670 ) -> account_pb2.CreateInviteCodeRes:
671 code = generate_invite_code()
672 session.add(InviteCode(id=code, creator_user_id=context.user_id))
674 return account_pb2.CreateInviteCodeRes(
675 code=code,
676 url=urls.invite_code_link(code=code),
677 )
679 def DisableInviteCode(
680 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session
681 ) -> empty_pb2.Empty:
682 invite = session.execute(
683 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id)
684 ).scalar_one_or_none()
686 if not invite:
687 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found")
689 invite.disabled = func.now()
690 session.commit()
692 return empty_pb2.Empty()
694 def ListInviteCodes(
695 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
696 ) -> account_pb2.ListInviteCodesRes:
697 results = session.execute(
698 select(
699 InviteCode.id,
700 InviteCode.created,
701 InviteCode.disabled,
702 func.count(User.id).label("num_users"),
703 )
704 .outerjoin(User, User.invite_code_id == InviteCode.id)
705 .where(InviteCode.creator_user_id == context.user_id)
706 .group_by(InviteCode.id, InviteCode.disabled)
707 .order_by(func.count(User.id).desc(), InviteCode.disabled)
708 ).all()
710 return account_pb2.ListInviteCodesRes(
711 invite_codes=[
712 account_pb2.InviteCodeInfo(
713 code=code_id,
714 created=Timestamp_from_datetime(created),
715 disabled=Timestamp_from_datetime(disabled) if disabled else None,
716 uses=len_users,
717 url=urls.invite_code_link(code=code_id),
718 )
719 for code_id, created, disabled, len_users in results
720 ]
721 )
723 def GetReminders(
724 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
725 ) -> account_pb2.GetRemindersRes:
726 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
728 # responding to reqs comes first in desc order of when they were received
729 pending_host_requests = session.execute(
730 select(HostRequest.conversation_id, LiteUser)
731 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
732 .where_users_column_visible(context, HostRequest.surfer_user_id)
733 .where_moderated_content_visible(context, HostRequest, is_list_operation=True)
734 .where(HostRequest.host_user_id == context.user_id)
735 .where(HostRequest.status == HostRequestStatus.pending)
736 .where(HostRequest.start_time > func.now())
737 .order_by(HostRequest.conversation_id.asc())
738 ).all()
739 reminders = [
740 account_pb2.Reminder(
741 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
742 host_request_id=host_request_id,
743 surfer_user=lite_user_to_pb(session, lite_user, context),
744 )
745 )
746 for host_request_id, lite_user in pending_host_requests
747 ]
749 # references come second, in order of deadline, desc
750 reminders += [
751 account_pb2.Reminder(
752 write_reference_reminder=account_pb2.WriteReferenceReminder(
753 host_request_id=host_request_id,
754 reference_type=reftype2api[reference_type],
755 other_user=lite_user_to_pb(session, lite_user, context),
756 )
757 )
758 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
759 ]
761 if not user.has_completed_profile:
762 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
764 if not has_strong_verification(session, user):
765 reminders.append(
766 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder())
767 )
769 return account_pb2.GetRemindersRes(reminders=reminders)
771 def GetMyVolunteerInfo(
772 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
773 ) -> account_pb2.GetMyVolunteerInfoRes:
774 user, volunteer = session.execute(
775 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
776 ).one()
777 if not volunteer:
778 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
779 return _volunteer_info_to_pb(volunteer, user.username)
781 def UpdateMyVolunteerInfo(
782 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session
783 ) -> account_pb2.GetMyVolunteerInfoRes:
784 user, volunteer = session.execute(
785 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
786 ).one()
787 if not volunteer:
788 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
790 if request.HasField("display_name"):
791 volunteer.display_name = request.display_name.value or None
793 if request.HasField("display_location"):
794 volunteer.display_location = request.display_location.value or None
796 if request.HasField("show_on_team_page"):
797 volunteer.show_on_team_page = request.show_on_team_page.value
799 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"):
800 link_type = request.link_type.value or volunteer.link_type
801 link_text = request.link_text.value or volunteer.link_text
802 link_url = request.link_url.value or volunteer.link_url
803 if link_type == "couchers":
804 # this is the default
805 link_type = None
806 link_text = None
807 link_url = None
808 elif link_type == "linkedin":
809 # this is the username
810 link_text = link_text
811 link_url = f"https://www.linkedin.com/in/{link_text}/"
812 elif link_type == "email":
813 if not is_valid_email(link_text):
814 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
815 link_url = f"mailto:{link_text}"
816 elif link_type == "website":
817 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url:
818 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url")
819 else:
820 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type")
821 volunteer.link_type = link_type
822 volunteer.link_text = link_text
823 volunteer.link_url = link_url
825 session.flush()
827 return _volunteer_info_to_pb(volunteer, user.username)
830class Iris(iris_pb2_grpc.IrisServicer):
831 def Webhook(
832 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
833 ) -> httpbody_pb2.HttpBody:
834 json_data = json.loads(request.data)
835 reference_payload = verification_pb2.VerificationReferencePayload.FromString(
836 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
837 )
838 # if we make it past the decrypt, we consider this webhook authenticated
839 verification_attempt_token = reference_payload.verification_attempt_token
840 user_id = reference_payload.user_id
842 verification_attempt = session.execute(
843 select(StrongVerificationAttempt)
844 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
845 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
846 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
847 ).scalar_one()
848 iris_status = json_data["session_state"]
849 session.add(
850 StrongVerificationCallbackEvent(
851 verification_attempt_id=verification_attempt.id,
852 iris_status=iris_status,
853 )
854 )
855 if iris_status == "INITIATED":
856 # the user opened the session in the app
857 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
858 elif iris_status == "COMPLETED":
859 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
860 elif iris_status == "APPROVED":
861 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
862 session.commit()
863 # background worker will go and sort this one out
864 queue_job(
865 session,
866 job_type="finalize_strong_verification",
867 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
868 priority=8,
869 )
870 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
871 verification_attempt.status = StrongVerificationAttemptStatus.failed
873 return httpbody_pb2.HttpBody(
874 content_type="application/json",
875 # json.dumps escapes non-ascii characters
876 data=json.dumps({"success": True}).encode("ascii"),
877 )