Coverage for app/backend/src/couchers/servicers/auth.py: 89%
355 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import logging
2from datetime import datetime, timedelta
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy import select
7from sqlalchemy.orm import Session
8from sqlalchemy.sql import delete, func, or_
10from couchers import urls
11from couchers.abuse import maybe_log_nonvisible_user_access
12from couchers.constants import ANTIBOT_FREQ, BANNED_USERNAME_PHRASES, GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS
13from couchers.context import CouchersContext
14from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password
15from couchers.event_log import log_event
16from couchers.helpers.hosting_meetup_status import record_hosting_meetup_status
17from couchers.metrics import (
18 account_deletion_completions_counter,
19 account_recoveries_counter,
20 antibot_score_histogram,
21 antibots_assessed_counter,
22 logins_counter,
23 password_reset_completions_counter,
24 password_reset_initiations_counter,
25 signup_account_filled_counter,
26 signup_completions_counter,
27 signup_email_changes_counter,
28 signup_email_verified_counter,
29 signup_guidelines_accepted_counter,
30 signup_initiations_counter,
31 signup_motivations_filled_counter,
32 signup_time_histogram,
33)
34from couchers.models import (
35 AccountDeletionToken,
36 AntiBotLog,
37 ContributorForm,
38 HostingMeetupStatusSource,
39 InviteCode,
40 ModerationObjectType,
41 NonvisibleUserAccessType,
42 PasswordResetToken,
43 PhotoGallery,
44 SignupFlow,
45 User,
46 UserSession,
47)
48from couchers.models.notifications import NotificationTopicAction
49from couchers.models.uploads import get_avatar_upload
50from couchers.moderation.utils import create_moderation
51from couchers.notifications.notify import notify
52from couchers.notifications.quick_links import decode_quick_link
53from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
54from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
55from couchers.servicers.api import hostingstatus2sql
56from couchers.servicers.auth_unsubscribe import handle_unsubscribe
57from couchers.sql import username_or_email
58from couchers.tasks import (
59 enforce_community_memberships_for_user,
60 maybe_send_contributor_form_email,
61 send_signup_email,
62)
63from couchers.utils import (
64 create_coordinate,
65 create_session_cookies,
66 is_geom,
67 is_valid_email,
68 is_valid_name,
69 is_valid_username,
70 minimum_allowed_birthdate,
71 not_none,
72 now,
73 parse_date,
74 parse_session_cookie,
75)
77logger = logging.getLogger(__name__)
80def _auth_res(user: User) -> auth_pb2.AuthRes:
81 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
84def create_session(
85 context: CouchersContext,
86 session: Session,
87 user: User,
88 long_lived: bool,
89 is_api_key: bool = False,
90 duration: timedelta | None = None,
91 set_cookie: bool = True,
92) -> tuple[str, datetime]:
93 """
94 Creates a session for the given user and returns the token and expiry.
96 You need to give an active DB session as nested sessions don't really
97 work here due to the active User object.
99 Will abort the API calling context if the user is banned from logging in.
101 You can set the cookie on the client (if `is_api_key=False`) with
103 ```py3
104 token, expiry = create_session(...)
105 ```
106 """
107 maybe_log_nonvisible_user_access(
108 context,
109 user,
110 access_type=NonvisibleUserAccessType.login_attempt,
111 actor_user_id=user.id,
112 )
114 if user.banned_at is not None:
115 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended")
117 # just double-check
118 assert user.deleted_at is None
120 token = cookiesafe_secure_token()
122 user_session = UserSession(
123 token=token,
124 user_id=user.id,
125 long_lived=long_lived,
126 ip_address=context.get_header("x-couchers-real-ip"),
127 user_agent=context.get_header("user-agent"),
128 is_api_key=is_api_key,
129 )
130 if duration:
131 user_session.expiry = func.now() + duration
133 session.add(user_session)
135 # read off the user before the commit expires it: every attribute touched after the commit re-selects the
136 # whole row, and this is on the path of every single login
137 user_id = user.id
138 user_gender = user.gender
140 session.commit()
142 logger.debug("Handing out %s to user %s", token, user_id)
144 if set_cookie:
145 context.set_cookies(create_session_cookies(token, user_id, user_session.expiry))
147 logins_counter.labels(user_gender).inc()
149 return token, user_session.expiry
152def delete_session(session: Session, token: str) -> bool:
153 """
154 Deletes the given session (practically logging the user out)
156 Returns True if the session was found, False otherwise.
157 """
158 user_session = session.execute(
159 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
160 ).scalar_one_or_none()
161 if user_session: 161 ↛ 166line 161 didn't jump to line 166 because the condition on line 161 was always true
162 user_session.deleted = func.now()
163 session.commit()
164 return True
165 else:
166 return False
169def _username_available(session: Session, username: str) -> bool:
170 """
171 Checks if the given username adheres to our rules and isn't taken already.
172 """
173 logger.debug(f"Checking if {username=} is valid")
174 if not is_valid_username(username):
175 return False
176 for phrase in BANNED_USERNAME_PHRASES:
177 if phrase.lower() in username.lower():
178 return False
179 # check for existing user with that username
180 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
181 # check for started signup with that username
182 signup_exists = (
183 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
184 )
185 # return False if user exists, True otherwise
186 return not user_exists and not signup_exists
189class Auth(auth_pb2_grpc.AuthServicer):
190 """
191 The Auth servicer.
193 This class services the Auth service/API.
194 """
196 def SignupFlow(
197 self, request: auth_pb2.SignupFlowReq, context: CouchersContext, session: Session
198 ) -> auth_pb2.SignupFlowRes:
199 # this is a bit ugly, probably one RPC for this mega-mutation of signup flows is not ideal
200 has_signup_step = (
201 request.HasField("basic")
202 or request.HasField("account")
203 or request.HasField("feedback")
204 or request.HasField("motivations")
205 or request.HasField("accept_community_guidelines")
206 )
207 has_recovery_step = request.HasField("change_email") or request.resend_verification_email
209 # if we have the token then we ignore all the other stuff, so better just error to the client
210 if request.email_token and (has_signup_step or has_recovery_step):
211 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request")
213 # just for safety
214 if has_signup_step and has_recovery_step:
215 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request")
217 if request.email_token:
218 # the email token can either be for verification or just to find an existing signup
219 flow = session.execute(
220 select(SignupFlow)
221 .where(SignupFlow.email_verified == False)
222 .where(SignupFlow.email_token == request.email_token)
223 .where(SignupFlow.token_is_valid)
224 ).scalar_one_or_none()
225 if flow:
226 # find flow by email verification token and mark it as verified
227 flow.email_verified = True
228 flow.email_token = None
229 flow.email_token_expiry = None
231 session.flush()
232 signup_email_verified_counter.inc()
233 else:
234 # just try to find the flow by flow token, no verification is done
235 flow = session.execute(
236 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
237 ).scalar_one_or_none()
238 if not flow: 238 ↛ 407line 238 didn't jump to line 407 because the condition on line 238 was always true
239 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
240 else:
242 def _check_email_is_available_and_valid(new_email: str) -> None:
243 # TODO: unique across both tables
244 existing_user = session.execute(select(User).where(User.email == new_email)).scalar_one_or_none()
245 if existing_user:
246 if not existing_user.is_visible:
247 context.abort_with_error_code(
248 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used"
249 )
250 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
251 existing_flow = session.execute(
252 select(SignupFlow).where(SignupFlow.email == new_email)
253 ).scalar_one_or_none()
254 if existing_flow:
255 send_signup_email(context, session, existing_flow)
256 session.commit()
257 context.abort_with_error_code(
258 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup"
259 )
261 if not is_valid_email(new_email):
262 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
264 if not request.flow_token:
265 # fresh signup
266 if not request.HasField("basic"):
267 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed")
268 _check_email_is_available_and_valid(request.basic.email)
269 if not is_valid_name(request.basic.name):
270 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
272 flow_token = cookiesafe_secure_token()
274 invite_id = None
275 if request.basic.invite_code:
276 invite_id = session.execute(
277 select(InviteCode.id).where(
278 InviteCode.id == request.basic.invite_code,
279 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
280 )
281 ).scalar_one_or_none()
282 if not invite_id: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true
283 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code")
285 flow = SignupFlow(
286 flow_token=flow_token,
287 name=request.basic.name,
288 email=request.basic.email,
289 invite_code_id=invite_id,
290 )
291 session.add(flow)
292 session.flush()
293 signup_initiations_counter.inc()
294 log_event(context, session, "account.signup_initiated", {"has_invite_code": invite_id is not None})
295 else:
296 # not fresh signup
297 flow = session.execute(
298 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
299 ).scalar_one_or_none()
300 if not flow: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
302 if request.HasField("basic"): 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled")
305 # we've found and/or created a new flow, now sort out other parts
307 if request.HasField("account"):
308 if flow.account_is_filled: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled")
311 # check username validity
312 if not is_valid_username(request.account.username):
313 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username")
315 if not _username_available(session, request.account.username):
316 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available")
318 abort_on_invalid_password(request.account.password, context)
319 hashed_password = hash_password(request.account.password)
321 birthdate = parse_date(request.account.birthdate)
322 if not birthdate or birthdate >= minimum_allowed_birthdate():
323 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate")
325 if not request.account.hosting_status:
326 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required")
328 if request.account.lat == 0 and request.account.lng == 0:
329 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
331 if not request.account.accept_tos: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_tos")
334 flow.username = request.account.username
335 flow.hashed_password = hashed_password
336 flow.birthdate = birthdate
337 flow.gender = request.account.gender
338 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
339 flow.city = request.account.city
340 flow.geom = create_coordinate(request.account.lat, request.account.lng)
341 flow.geom_radius = request.account.radius
342 flow.accepted_tos = TOS_VERSION
343 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
344 session.flush()
345 signup_account_filled_counter.inc()
347 if request.HasField("feedback"):
348 if flow.filled_feedback: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true
349 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_feedback_filled")
350 form = request.feedback
352 flow.filled_feedback = True
353 flow.ideas = form.ideas
354 flow.features = form.features
355 flow.experience = form.experience
356 flow.contribute = contributeoption2sql[form.contribute]
357 flow.contribute_ways = form.contribute_ways # type: ignore[assignment]
358 flow.expertise = form.expertise
359 session.flush()
361 if request.HasField("motivations"):
362 if flow.filled_motivations:
363 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_motivations_filled")
365 flow.filled_motivations = True
366 flow.heard_about_couchers = request.motivations.heard_about_couchers or None
367 flow.signup_motivations = list(request.motivations.motivations)
368 session.flush()
369 signup_motivations_filled_counter.inc()
371 if request.HasField("accept_community_guidelines"):
372 if not request.accept_community_guidelines.value: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true
373 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines")
374 if flow.accepted_community_guidelines < GUIDELINES_VERSION:
375 signup_guidelines_accepted_counter.inc()
376 flow.accepted_community_guidelines = GUIDELINES_VERSION
377 session.flush()
379 if request.HasField("change_email"):
380 # can't change after verifying
381 if flow.email_verified:
382 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_verified")
383 # can't resend verification at the same time
384 if request.resend_verification_email:
385 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request")
387 # this will error if the email is not different from the current one
388 _check_email_is_available_and_valid(request.change_email.new_email)
390 flow.email = request.change_email.new_email
391 flow.email_verified = False
392 flow.email_sent = False
393 flow.email_token = None
394 flow.email_token_expiry = None
395 flow.email_changed_count += 1
396 signup_email_changes_counter.inc()
398 session.flush()
400 # send verification email if needed
401 if not flow.email_sent or request.resend_verification_email:
402 send_signup_email(context, session, flow)
404 session.flush()
406 # finish the signup if done
407 if flow.is_completed:
408 user: User | None = None
410 def create_user(moderation_state_id: int) -> int:
411 nonlocal user
412 user = User(
413 name=flow.name,
414 email=flow.email,
415 username=not_none(flow.username),
416 hashed_password=not_none(flow.hashed_password),
417 birthdate=not_none(flow.birthdate),
418 gender=not_none(flow.gender),
419 hosting_status=not_none(flow.hosting_status),
420 city=not_none(flow.city),
421 geom=is_geom(flow.geom),
422 geom_radius=not_none(flow.geom_radius),
423 accepted_tos=not_none(flow.accepted_tos),
424 last_onboarding_email_sent=func.now(),
425 invite_code_id=flow.invite_code_id,
426 heard_about_couchers=flow.heard_about_couchers,
427 signup_motivations=flow.signup_motivations if flow.filled_motivations else None,
428 moderation_state_id=moderation_state_id,
429 )
431 user.accepted_community_guidelines = flow.accepted_community_guidelines
432 user.onboarding_emails_sent = 1
433 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter)
435 session.add(user)
436 session.flush()
437 return user.id
439 create_moderation(
440 session=session,
441 object_type=ModerationObjectType.user,
442 object_id=create_user,
443 )
444 assert user is not None
446 record_hosting_meetup_status(session, user, HostingMeetupStatusSource.signup)
448 # Create a profile gallery for the user
449 profile_gallery = PhotoGallery(owner_user_id=user.id)
450 session.add(profile_gallery)
451 session.flush()
452 user.profile_gallery_id = profile_gallery.id
454 if flow.filled_feedback:
455 form_ = ContributorForm(
456 user_id=user.id,
457 ideas=flow.ideas or None,
458 features=flow.features or None,
459 experience=flow.experience or None,
460 contribute=flow.contribute or None,
461 contribute_ways=not_none(flow.contribute_ways),
462 expertise=flow.expertise or None,
463 )
465 session.add(form_)
467 user.filled_contributor_form = form_.is_filled
469 maybe_send_contributor_form_email(session, form_)
471 signup_duration_s = (now() - flow.created).total_seconds()
473 session.delete(flow)
474 session.commit()
476 enforce_community_memberships_for_user(session, user)
478 # sends onboarding email
479 notify(
480 session,
481 user_id=user.id,
482 topic_action=NotificationTopicAction.onboarding__reminder,
483 key="1",
484 )
486 signup_completions_counter.labels(flow.gender).inc()
487 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
488 log_event(
489 context,
490 session,
491 "account.signup_completed",
492 {
493 "gender": flow.gender,
494 "signup_duration_s": signup_duration_s,
495 "hosting_status": str(flow.hosting_status),
496 "city": flow.city,
497 "has_invite_code": flow.invite_code_id is not None,
498 "filled_contributor_form": user.filled_contributor_form,
499 },
500 _override_user_id=user.id,
501 )
503 create_session(context, session, user, False)
504 return auth_pb2.SignupFlowRes(
505 auth_res=_auth_res(user),
506 )
507 else:
508 return auth_pb2.SignupFlowRes(
509 flow_token=flow.flow_token,
510 email=flow.email,
511 need_account=not flow.account_is_filled,
512 need_feedback=False,
513 need_verify_email=not flow.email_verified,
514 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
515 need_motivations=not flow.filled_motivations,
516 )
518 def UsernameValid(
519 self, request: auth_pb2.UsernameValidReq, context: CouchersContext, session: Session
520 ) -> auth_pb2.UsernameValidRes:
521 """
522 Runs a username availability and validity check.
523 """
524 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
526 def Authenticate(self, request: auth_pb2.AuthReq, context: CouchersContext, session: Session) -> auth_pb2.AuthRes:
527 """
528 Authenticates a classic password-based login request.
530 request.user can be any of id/username/email
531 """
532 logger.debug(f"Logging in with {request.user=}, password=*******")
533 user = session.execute(
534 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
535 ).scalar_one_or_none()
536 if user:
537 logger.debug("Found user")
538 if verify_password(user.hashed_password, request.password):
539 logger.debug("Right password")
540 # correct password
541 create_session(context, session, user, request.remember_device)
542 log_event(
543 context,
544 session,
545 "account.login",
546 {"gender": user.gender, "remember_device": request.remember_device},
547 _override_user_id=user.id,
548 )
549 return _auth_res(user)
550 else:
551 logger.debug("Wrong password")
552 # wrong password
553 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password_login")
554 else: # user not found
555 # check if this is an email and they tried to sign up but didn't complete
556 signup_flow = session.execute(
557 select(SignupFlow).where(username_or_email(request.user, table=SignupFlow))
558 ).scalar_one_or_none()
559 if signup_flow:
560 send_signup_email(context, session, signup_flow)
561 session.commit()
562 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup")
563 logger.debug("Didn't find user")
564 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found")
566 def GetAuthState(
567 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
568 ) -> auth_pb2.GetAuthStateRes:
569 if not context.is_logged_in():
570 return auth_pb2.GetAuthStateRes(logged_in=False)
571 else:
572 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
573 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
575 def Deauthenticate(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> empty_pb2.Empty:
576 """
577 Removes an active cookie session.
578 """
579 token = parse_session_cookie(context.headers)
580 logger.info(f"Deauthenticate(token={token})")
582 # if we had a token, try to remove the session
583 if token: 583 ↛ 586line 583 didn't jump to line 586 because the condition on line 583 was always true
584 delete_session(session, token)
586 log_event(context, session, "account.logout", {})
588 # set the cookie to an empty string and expire immediately, should remove it from the browser
589 context.set_cookies(create_session_cookies("", "", now()))
591 return empty_pb2.Empty()
593 def ResetPassword(
594 self, request: auth_pb2.ResetPasswordReq, context: CouchersContext, session: Session
595 ) -> empty_pb2.Empty:
596 """
597 If the user does not exist, do nothing.
599 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
600 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
602 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
603 """
604 user = session.execute(
605 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
606 ).scalar_one_or_none()
607 if user:
608 password_reset_token = PasswordResetToken(
609 token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)
610 )
611 session.add(password_reset_token)
612 session.flush()
614 notify(
615 session,
616 user_id=user.id,
617 topic_action=NotificationTopicAction.password_reset__start,
618 key="",
619 data=notification_data_pb2.PasswordResetStart(
620 password_reset_token=password_reset_token.token,
621 ),
622 )
624 password_reset_initiations_counter.inc()
625 log_event(
626 context,
627 session,
628 "account.password_reset_initiated",
629 {},
630 _override_user_id=user.id,
631 )
632 else: # user not found
633 logger.debug("Didn't find user")
635 return empty_pb2.Empty()
637 def CompletePasswordResetV2(
638 self, request: auth_pb2.CompletePasswordResetV2Req, context: CouchersContext, session: Session
639 ) -> auth_pb2.AuthRes:
640 """
641 Completes the password reset: just clears the user's password
642 """
643 res = session.execute(
644 select(PasswordResetToken, User)
645 .join(User, User.id == PasswordResetToken.user_id)
646 .where(PasswordResetToken.token == request.password_reset_token)
647 .where(PasswordResetToken.is_valid)
648 ).one_or_none()
649 if res:
650 password_reset_token, user = res
651 abort_on_invalid_password(request.new_password, context)
652 user.hashed_password = hash_password(request.new_password)
653 session.delete(password_reset_token)
655 session.flush()
657 notify(
658 session,
659 user_id=user.id,
660 topic_action=NotificationTopicAction.password_reset__complete,
661 key="",
662 )
664 create_session(context, session, user, False)
665 password_reset_completions_counter.inc()
666 log_event(
667 context,
668 session,
669 "account.password_reset_completed",
670 {},
671 _override_user_id=user.id,
672 )
673 return _auth_res(user)
674 else:
675 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
677 def ConfirmChangeEmailV2(
678 self, request: auth_pb2.ConfirmChangeEmailV2Req, context: CouchersContext, session: Session
679 ) -> empty_pb2.Empty:
680 user = session.execute(
681 select(User)
682 .where(User.new_email_token == request.change_email_token)
683 .where(User.new_email_token_created <= now())
684 .where(User.new_email_token_expiry >= now())
685 ).scalar_one_or_none()
687 if not user:
688 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
690 user.email = not_none(user.new_email)
691 user.new_email = None
692 user.new_email_token = None
693 user.new_email_token_created = None
694 user.new_email_token_expiry = None
696 notify(
697 session,
698 user_id=user.id,
699 topic_action=NotificationTopicAction.email_address__verify,
700 key="",
701 )
703 log_event(context, session, "account.email_confirmed", {}, _override_user_id=user.id)
705 return empty_pb2.Empty()
707 def ConfirmDeleteAccount(
708 self, request: auth_pb2.ConfirmDeleteAccountReq, context: CouchersContext, session: Session
709 ) -> empty_pb2.Empty:
710 """
711 Confirm account deletion using account delete token
712 """
713 res = session.execute(
714 select(User, AccountDeletionToken)
715 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
716 .where(AccountDeletionToken.token == request.token)
717 .where(AccountDeletionToken.is_valid)
718 ).one_or_none()
720 if not res: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true
721 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
723 user, account_deletion_token = res
725 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
727 user.deleted_at = now()
728 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
729 user.undelete_token = urlsafe_secure_token()
731 session.flush()
733 notify(
734 session,
735 user_id=user.id,
736 topic_action=NotificationTopicAction.account_deletion__complete,
737 key="",
738 data=notification_data_pb2.AccountDeletionComplete(
739 undelete_token=user.undelete_token,
740 undelete_days=UNDELETE_DAYS,
741 ),
742 )
744 account_deletion_completions_counter.labels(user.gender).inc()
745 log_event(
746 context,
747 session,
748 "account.deletion_completed",
749 {"gender": user.gender},
750 _override_user_id=user.id,
751 )
753 return empty_pb2.Empty()
755 def RecoverAccount(
756 self, request: auth_pb2.RecoverAccountReq, context: CouchersContext, session: Session
757 ) -> empty_pb2.Empty:
758 """
759 Recovers a recently deleted account
760 """
761 user = session.execute(
762 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
763 ).scalar_one_or_none()
765 if not user: 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true
766 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
768 user.deleted_at = None
769 user.undelete_token = None
770 user.undelete_until = None
772 notify(
773 session,
774 user_id=user.id,
775 topic_action=NotificationTopicAction.account_deletion__recovered,
776 key="",
777 )
779 account_recoveries_counter.labels(user.gender).inc()
780 log_event(
781 context,
782 session,
783 "account.recovered",
784 {"gender": user.gender},
785 _override_user_id=user.id,
786 )
788 return empty_pb2.Empty()
790 def Unsubscribe(
791 self, request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session
792 ) -> auth_pb2.UnsubscribeRes:
793 payload = decode_quick_link(request.payload, request.sig, context)
794 return auth_pb2.UnsubscribeRes(response=handle_unsubscribe(payload, context, session))
796 def AntiBot(self, request: auth_pb2.AntiBotReq, context: CouchersContext, session: Session) -> auth_pb2.AntiBotRes:
797 if not context.get_boolean_value("antibot_enabled", default=False):
798 return auth_pb2.AntiBotRes()
800 ip_address = context.get_header("x-couchers-real-ip")
801 user_agent = context.get_header("user-agent")
802 user_id = context.user_id if context.is_logged_in() else None
804 log = AntiBotLog(
805 token=request.token,
806 user_agent=user_agent,
807 ip_address=ip_address,
808 action=request.action,
809 user_id=user_id,
810 # placeholders: there is currently no provider assessing requests
811 score=0.0,
812 provider_data={},
813 )
815 session.add(log)
816 session.flush()
818 antibots_assessed_counter.labels(log.action).inc()
819 antibot_score_histogram.labels(log.action).observe(log.score)
821 if context.is_logged_in():
822 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
823 user.last_antibot = now()
825 return auth_pb2.AntiBotRes()
827 def AntiBotPolicy(
828 self, request: auth_pb2.AntiBotPolicyReq, context: CouchersContext, session: Session
829 ) -> auth_pb2.AntiBotPolicyRes:
830 if context.get_boolean_value("antibot_enabled", default=False):
831 if context.is_logged_in():
832 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
833 if now() - user.last_antibot > ANTIBOT_FREQ:
834 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
836 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
838 def GetInviteCodeInfo(
839 self, request: auth_pb2.GetInviteCodeInfoReq, context: CouchersContext, session: Session
840 ) -> auth_pb2.GetInviteCodeInfoRes:
841 invite = session.execute(
842 select(InviteCode).where(
843 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
844 )
845 ).scalar_one_or_none()
847 if not invite:
848 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found")
850 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
852 avatar_upload = get_avatar_upload(session, user)
854 return auth_pb2.GetInviteCodeInfoRes(
855 name=user.name,
856 username=user.username,
857 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None,
858 url=urls.invite_code_link(code=request.code),
859 )