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
« 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
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 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
79logger = logging.getLogger(__name__)
80logger.setLevel(logging.DEBUG)
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}
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}
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}
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}
114MAX_PAGINATION_LENGTH = 50
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 )
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")
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")
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")
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 )
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 )
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()
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 )
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.
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()
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")
207 abort_on_invalid_password(request.new_password, context)
208 user.hashed_password = hash_password(request.new_password)
210 session.commit()
212 notify(
213 session,
214 user_id=user.id,
215 topic_action="password:change",
216 )
218 return empty_pb2.Empty()
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.
226 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
228 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
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()
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")
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")
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")
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)
252 send_email_changed_confirmation_to_new_email(session, user)
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 )
264 # session autocommit
265 return empty_pb2.Empty()
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()
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))
277 return empty_pb2.Empty()
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()
284 form = request.contributor_form
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 )
296 session.add(form)
297 session.flush()
298 maybe_send_contributor_form_email(session, form)
300 user.filled_contributor_form = True
302 return empty_pb2.Empty()
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()
309 return account_pb2.GetContributorFormInfoRes(
310 filled_contributor_form=user.filled_contributor_form,
311 )
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")
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")
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()
332 if not is_known_operator(phone):
333 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
335 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
336 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
338 token = sms.generate_random_code()
339 result = sms.send_sms(phone, sms.format_message(token))
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
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 )
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 data=notification_data_pb2.PhoneNumberVerify(
407 phone=user.phone,
408 ),
409 )
411 return empty_pb2.Empty()
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")
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")
428 strong_verification_initiations_counter.labels(user.gender).inc()
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 )
454 if response.status_code != 200:
455 raise Exception(f"Iris didn't return 200: {response.text}")
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 )
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)
476 return account_pb2.InitiateStrongVerificationRes(
477 verification_attempt_token=verification_attempt_token,
478 redirect_url=redirect_url,
479 )
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 )
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
536 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
537 strong_verification_data_deletions_counter.labels(user.gender).inc()
539 return empty_pb2.Empty()
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
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")
552 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
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)
561 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
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)
573 account_deletion_initiations_counter.labels(user.gender).inc()
575 return empty_pb2.Empty()
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()
582 notes = (
583 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
584 .scalars()
585 .all()
586 )
588 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
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()
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 )
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 )
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 )
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()
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")
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()
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()
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))
672 return account_pb2.CreateInviteCodeRes(
673 code=code,
674 url=urls.invite_code_link(code=code),
675 )
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()
684 if not invite:
685 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found")
687 invite.disabled = func.now()
688 session.commit()
690 return empty_pb2.Empty()
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()
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 )
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()
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 ]
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 ]
758 if not user.has_completed_profile:
759 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
761 if not has_strong_verification(session, user):
762 reminders.append(
763 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder())
764 )
766 return account_pb2.GetRemindersRes(reminders=reminders)
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)
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")
787 if request.HasField("display_name"):
788 volunteer.display_name = request.display_name.value or None
790 if request.HasField("display_location"):
791 volunteer.display_location = request.display_location.value or None
793 if request.HasField("show_on_team_page"):
794 volunteer.show_on_team_page = request.show_on_team_page.value
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
822 session.flush()
824 return _volunteer_info_to_pb(volunteer, user.username)
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
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
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 )