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