Coverage for app/backend/src/couchers/servicers/auth.py: 86%
323 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +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.metrics import (
17 account_deletion_completions_counter,
18 account_recoveries_counter,
19 antibot_score_histogram,
20 antibots_assessed_counter,
21 logins_counter,
22 password_reset_completions_counter,
23 password_reset_initiations_counter,
24 signup_account_filled_counter,
25 signup_completions_counter,
26 signup_email_verified_counter,
27 signup_guidelines_accepted_counter,
28 signup_initiations_counter,
29 signup_motivations_filled_counter,
30 signup_time_histogram,
31)
32from couchers.models import (
33 AccountDeletionToken,
34 AntiBotLog,
35 ContributorForm,
36 InviteCode,
37 NonvisibleUserAccessType,
38 PasswordResetToken,
39 PhotoGallery,
40 SignupFlow,
41 User,
42 UserSession,
43)
44from couchers.models.notifications import NotificationTopicAction
45from couchers.models.uploads import get_avatar_upload
46from couchers.notifications.notify import notify
47from couchers.notifications.quick_links import decode_quick_link
48from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
49from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
50from couchers.servicers.api import hostingstatus2sql
51from couchers.servicers.auth_unsubscribe import handle_unsubscribe
52from couchers.sql import username_or_email
53from couchers.tasks import (
54 enforce_community_memberships_for_user,
55 maybe_send_contributor_form_email,
56 send_signup_email,
57)
58from couchers.utils import (
59 create_coordinate,
60 create_session_cookies,
61 is_geom,
62 is_valid_email,
63 is_valid_name,
64 is_valid_username,
65 minimum_allowed_birthdate,
66 not_none,
67 now,
68 parse_date,
69 parse_session_cookie,
70)
72logger = logging.getLogger(__name__)
75def _auth_res(user: User) -> auth_pb2.AuthRes:
76 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
79def create_session(
80 context: CouchersContext,
81 session: Session,
82 user: User,
83 long_lived: bool,
84 is_api_key: bool = False,
85 duration: timedelta | None = None,
86 set_cookie: bool = True,
87) -> tuple[str, datetime]:
88 """
89 Creates a session for the given user and returns the token and expiry.
91 You need to give an active DB session as nested sessions don't really
92 work here due to the active User object.
94 Will abort the API calling context if the user is banned from logging in.
96 You can set the cookie on the client (if `is_api_key=False`) with
98 ```py3
99 token, expiry = create_session(...)
100 ```
101 """
102 maybe_log_nonvisible_user_access(
103 context,
104 user,
105 access_type=NonvisibleUserAccessType.login_attempt,
106 actor_user_id=user.id,
107 )
109 if user.banned_at is not None:
110 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended")
112 # just double-check
113 assert user.deleted_at is None
115 token = cookiesafe_secure_token()
117 user_session = UserSession(
118 token=token,
119 user_id=user.id,
120 long_lived=long_lived,
121 ip_address=context.get_header("x-couchers-real-ip"),
122 user_agent=context.get_header("user-agent"),
123 is_api_key=is_api_key,
124 )
125 if duration:
126 user_session.expiry = func.now() + duration
128 session.add(user_session)
129 session.commit()
131 logger.debug(f"Handing out {token=} to {user=}")
133 if set_cookie:
134 context.set_cookies(create_session_cookies(token, user.id, user_session.expiry))
136 logins_counter.labels(user.gender).inc()
138 return token, user_session.expiry
141def delete_session(session: Session, token: str) -> bool:
142 """
143 Deletes the given session (practically logging the user out)
145 Returns True if the session was found, False otherwise.
146 """
147 user_session = session.execute(
148 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
149 ).scalar_one_or_none()
150 if user_session: 150 ↛ 155line 150 didn't jump to line 155 because the condition on line 150 was always true
151 user_session.deleted = func.now()
152 session.commit()
153 return True
154 else:
155 return False
158def _username_available(session: Session, username: str) -> bool:
159 """
160 Checks if the given username adheres to our rules and isn't taken already.
161 """
162 logger.debug(f"Checking if {username=} is valid")
163 if not is_valid_username(username):
164 return False
165 for phrase in BANNED_USERNAME_PHRASES:
166 if phrase.lower() in username.lower():
167 return False
168 # check for existing user with that username
169 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
170 # check for started signup with that username
171 signup_exists = (
172 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
173 )
174 # return False if user exists, True otherwise
175 return not user_exists and not signup_exists
178class Auth(auth_pb2_grpc.AuthServicer):
179 """
180 The Auth servicer.
182 This class services the Auth service/API.
183 """
185 def SignupFlow(
186 self, request: auth_pb2.SignupFlowReq, context: CouchersContext, session: Session
187 ) -> auth_pb2.SignupFlowRes:
188 if request.email_token:
189 # the email token can either be for verification or just to find an existing signup
190 flow = session.execute(
191 select(SignupFlow)
192 .where(SignupFlow.email_verified == False)
193 .where(SignupFlow.email_token == request.email_token)
194 .where(SignupFlow.token_is_valid)
195 ).scalar_one_or_none()
196 if flow: 196 ↛ 206line 196 didn't jump to line 206 because the condition on line 196 was always true
197 # find flow by email verification token and mark it as verified
198 flow.email_verified = True
199 flow.email_token = None
200 flow.email_token_expiry = None
202 session.flush()
203 signup_email_verified_counter.inc()
204 else:
205 # just try to find the flow by flow token, no verification is done
206 flow = session.execute(
207 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
208 ).scalar_one_or_none()
209 if not flow:
210 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
211 else:
212 if not request.flow_token:
213 # fresh signup
214 if not request.HasField("basic"): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed")
216 # TODO: unique across both tables
217 existing_user = session.execute(
218 select(User).where(User.email == request.basic.email)
219 ).scalar_one_or_none()
220 if existing_user:
221 if not existing_user.is_visible:
222 context.abort_with_error_code(
223 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used"
224 )
225 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
226 existing_flow = session.execute(
227 select(SignupFlow).where(SignupFlow.email == request.basic.email)
228 ).scalar_one_or_none()
229 if existing_flow:
230 send_signup_email(context, session, existing_flow)
231 session.commit()
232 context.abort_with_error_code(
233 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup"
234 )
236 if not is_valid_email(request.basic.email):
237 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
238 if not is_valid_name(request.basic.name):
239 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
241 flow_token = cookiesafe_secure_token()
243 invite_id = None
244 if request.basic.invite_code:
245 invite_id = session.execute(
246 select(InviteCode.id).where(
247 InviteCode.id == request.basic.invite_code,
248 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
249 )
250 ).scalar_one_or_none()
251 if not invite_id: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code")
254 flow = SignupFlow(
255 flow_token=flow_token,
256 name=request.basic.name,
257 email=request.basic.email,
258 invite_code_id=invite_id,
259 )
260 session.add(flow)
261 session.flush()
262 signup_initiations_counter.inc()
263 log_event(context, session, "account.signup_initiated", {"has_invite_code": invite_id is not None})
264 else:
265 # not fresh signup
266 flow = session.execute(
267 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
268 ).scalar_one_or_none()
269 if not flow: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
271 if request.HasField("basic"): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled")
274 # we've found and/or created a new flow, now sort out other parts
275 if request.HasField("account"):
276 if flow.account_is_filled: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled")
279 # check username validity
280 if not is_valid_username(request.account.username):
281 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username")
283 if not _username_available(session, request.account.username):
284 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available")
286 abort_on_invalid_password(request.account.password, context)
287 hashed_password = hash_password(request.account.password)
289 birthdate = parse_date(request.account.birthdate)
290 if not birthdate or birthdate >= minimum_allowed_birthdate():
291 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate")
293 if not request.account.hosting_status:
294 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required")
296 if request.account.lat == 0 and request.account.lng == 0:
297 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
299 if not request.account.accept_tos: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true
300 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_tos")
302 flow.username = request.account.username
303 flow.hashed_password = hashed_password
304 flow.birthdate = birthdate
305 flow.gender = request.account.gender
306 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
307 flow.city = request.account.city
308 flow.geom = create_coordinate(request.account.lat, request.account.lng)
309 flow.geom_radius = request.account.radius
310 flow.accepted_tos = TOS_VERSION
311 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
312 session.flush()
313 signup_account_filled_counter.inc()
315 if request.HasField("feedback"):
316 if flow.filled_feedback: 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
317 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_feedback_filled")
318 form = request.feedback
320 flow.filled_feedback = True
321 flow.ideas = form.ideas
322 flow.features = form.features
323 flow.experience = form.experience
324 flow.contribute = contributeoption2sql[form.contribute]
325 flow.contribute_ways = form.contribute_ways # type: ignore[assignment]
326 flow.expertise = form.expertise
327 session.flush()
329 if request.HasField("motivations"):
330 if flow.filled_motivations:
331 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_motivations_filled")
333 flow.filled_motivations = True
334 flow.heard_about_couchers = request.motivations.heard_about_couchers or None
335 flow.signup_motivations = list(request.motivations.motivations)
336 session.flush()
337 signup_motivations_filled_counter.inc()
339 if request.HasField("accept_community_guidelines"):
340 if not request.accept_community_guidelines.value: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true
341 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines")
342 if flow.accepted_community_guidelines < GUIDELINES_VERSION:
343 signup_guidelines_accepted_counter.inc()
344 flow.accepted_community_guidelines = GUIDELINES_VERSION
345 session.flush()
347 # send verification email if needed
348 if not flow.email_sent or request.resend_verification_email:
349 send_signup_email(context, session, flow)
351 session.flush()
353 # finish the signup if done
354 if flow.is_completed:
355 user = User(
356 name=flow.name,
357 email=flow.email,
358 username=not_none(flow.username),
359 hashed_password=not_none(flow.hashed_password),
360 birthdate=not_none(flow.birthdate),
361 gender=not_none(flow.gender),
362 hosting_status=not_none(flow.hosting_status),
363 city=not_none(flow.city),
364 geom=is_geom(flow.geom),
365 geom_radius=not_none(flow.geom_radius),
366 accepted_tos=not_none(flow.accepted_tos),
367 last_onboarding_email_sent=func.now(),
368 invite_code_id=flow.invite_code_id,
369 heard_about_couchers=flow.heard_about_couchers,
370 signup_motivations=flow.signup_motivations if flow.filled_motivations else None,
371 )
373 user.accepted_community_guidelines = flow.accepted_community_guidelines
374 user.onboarding_emails_sent = 1
375 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter)
377 session.add(user)
378 session.flush()
380 # Create a profile gallery for the user
381 profile_gallery = PhotoGallery(owner_user_id=user.id)
382 session.add(profile_gallery)
383 session.flush()
384 user.profile_gallery_id = profile_gallery.id
386 if flow.filled_feedback:
387 form_ = ContributorForm(
388 user_id=user.id,
389 ideas=flow.ideas or None,
390 features=flow.features or None,
391 experience=flow.experience or None,
392 contribute=flow.contribute or None,
393 contribute_ways=not_none(flow.contribute_ways),
394 expertise=flow.expertise or None,
395 )
397 session.add(form_)
399 user.filled_contributor_form = form_.is_filled
401 maybe_send_contributor_form_email(session, form_)
403 signup_duration_s = (now() - flow.created).total_seconds()
405 session.delete(flow)
406 session.commit()
408 enforce_community_memberships_for_user(session, user)
410 # sends onboarding email
411 notify(
412 session,
413 user_id=user.id,
414 topic_action=NotificationTopicAction.onboarding__reminder,
415 key="1",
416 )
418 signup_completions_counter.labels(flow.gender).inc()
419 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
420 log_event(
421 context,
422 session,
423 "account.signup_completed",
424 {
425 "gender": flow.gender,
426 "signup_duration_s": signup_duration_s,
427 "hosting_status": str(flow.hosting_status),
428 "city": flow.city,
429 "has_invite_code": flow.invite_code_id is not None,
430 "filled_contributor_form": user.filled_contributor_form,
431 },
432 _override_user_id=user.id,
433 )
435 create_session(context, session, user, False)
436 return auth_pb2.SignupFlowRes(
437 auth_res=_auth_res(user),
438 )
439 else:
440 return auth_pb2.SignupFlowRes(
441 flow_token=flow.flow_token,
442 need_account=not flow.account_is_filled,
443 need_feedback=False,
444 need_verify_email=not flow.email_verified,
445 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
446 need_motivations=not flow.filled_motivations,
447 )
449 def UsernameValid(
450 self, request: auth_pb2.UsernameValidReq, context: CouchersContext, session: Session
451 ) -> auth_pb2.UsernameValidRes:
452 """
453 Runs a username availability and validity check.
454 """
455 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
457 def Authenticate(self, request: auth_pb2.AuthReq, context: CouchersContext, session: Session) -> auth_pb2.AuthRes:
458 """
459 Authenticates a classic password-based login request.
461 request.user can be any of id/username/email
462 """
463 logger.debug(f"Logging in with {request.user=}, password=*******")
464 user = session.execute(
465 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
466 ).scalar_one_or_none()
467 if user:
468 logger.debug("Found user")
469 if verify_password(user.hashed_password, request.password):
470 logger.debug("Right password")
471 # correct password
472 create_session(context, session, user, request.remember_device)
473 log_event(
474 context,
475 session,
476 "account.login",
477 {"gender": user.gender, "remember_device": request.remember_device},
478 _override_user_id=user.id,
479 )
480 return _auth_res(user)
481 else:
482 logger.debug("Wrong password")
483 # wrong password
484 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password_login")
485 else: # user not found
486 # check if this is an email and they tried to sign up but didn't complete
487 signup_flow = session.execute(
488 select(SignupFlow).where(username_or_email(request.user, table=SignupFlow))
489 ).scalar_one_or_none()
490 if signup_flow:
491 send_signup_email(context, session, signup_flow)
492 session.commit()
493 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup")
494 logger.debug("Didn't find user")
495 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found")
497 def GetAuthState(
498 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
499 ) -> auth_pb2.GetAuthStateRes:
500 if not context.is_logged_in():
501 return auth_pb2.GetAuthStateRes(logged_in=False)
502 else:
503 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
504 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
506 def Deauthenticate(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> empty_pb2.Empty:
507 """
508 Removes an active cookie session.
509 """
510 token = parse_session_cookie(context.headers)
511 logger.info(f"Deauthenticate(token={token})")
513 # if we had a token, try to remove the session
514 if token: 514 ↛ 517line 514 didn't jump to line 517 because the condition on line 514 was always true
515 delete_session(session, token)
517 log_event(context, session, "account.logout", {})
519 # set the cookie to an empty string and expire immediately, should remove it from the browser
520 context.set_cookies(create_session_cookies("", "", now()))
522 return empty_pb2.Empty()
524 def ResetPassword(
525 self, request: auth_pb2.ResetPasswordReq, context: CouchersContext, session: Session
526 ) -> empty_pb2.Empty:
527 """
528 If the user does not exist, do nothing.
530 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
531 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
533 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
534 """
535 user = session.execute(
536 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
537 ).scalar_one_or_none()
538 if user:
539 password_reset_token = PasswordResetToken(
540 token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)
541 )
542 session.add(password_reset_token)
543 session.flush()
545 notify(
546 session,
547 user_id=user.id,
548 topic_action=NotificationTopicAction.password_reset__start,
549 key="",
550 data=notification_data_pb2.PasswordResetStart(
551 password_reset_token=password_reset_token.token,
552 ),
553 )
555 password_reset_initiations_counter.inc()
556 log_event(
557 context,
558 session,
559 "account.password_reset_initiated",
560 {},
561 _override_user_id=user.id,
562 )
563 else: # user not found
564 logger.debug("Didn't find user")
566 return empty_pb2.Empty()
568 def CompletePasswordResetV2(
569 self, request: auth_pb2.CompletePasswordResetV2Req, context: CouchersContext, session: Session
570 ) -> auth_pb2.AuthRes:
571 """
572 Completes the password reset: just clears the user's password
573 """
574 res = session.execute(
575 select(PasswordResetToken, User)
576 .join(User, User.id == PasswordResetToken.user_id)
577 .where(PasswordResetToken.token == request.password_reset_token)
578 .where(PasswordResetToken.is_valid)
579 ).one_or_none()
580 if res:
581 password_reset_token, user = res
582 abort_on_invalid_password(request.new_password, context)
583 user.hashed_password = hash_password(request.new_password)
584 session.delete(password_reset_token)
586 session.flush()
588 notify(
589 session,
590 user_id=user.id,
591 topic_action=NotificationTopicAction.password_reset__complete,
592 key="",
593 )
595 create_session(context, session, user, False)
596 password_reset_completions_counter.inc()
597 log_event(
598 context,
599 session,
600 "account.password_reset_completed",
601 {},
602 _override_user_id=user.id,
603 )
604 return _auth_res(user)
605 else:
606 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
608 def ConfirmChangeEmailV2(
609 self, request: auth_pb2.ConfirmChangeEmailV2Req, context: CouchersContext, session: Session
610 ) -> empty_pb2.Empty:
611 user = session.execute(
612 select(User)
613 .where(User.new_email_token == request.change_email_token)
614 .where(User.new_email_token_created <= now())
615 .where(User.new_email_token_expiry >= now())
616 ).scalar_one_or_none()
618 if not user:
619 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
621 user.email = not_none(user.new_email)
622 user.new_email = None
623 user.new_email_token = None
624 user.new_email_token_created = None
625 user.new_email_token_expiry = None
627 notify(
628 session,
629 user_id=user.id,
630 topic_action=NotificationTopicAction.email_address__verify,
631 key="",
632 )
634 log_event(context, session, "account.email_confirmed", {}, _override_user_id=user.id)
636 return empty_pb2.Empty()
638 def ConfirmDeleteAccount(
639 self, request: auth_pb2.ConfirmDeleteAccountReq, context: CouchersContext, session: Session
640 ) -> empty_pb2.Empty:
641 """
642 Confirm account deletion using account delete token
643 """
644 res = session.execute(
645 select(User, AccountDeletionToken)
646 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
647 .where(AccountDeletionToken.token == request.token)
648 .where(AccountDeletionToken.is_valid)
649 ).one_or_none()
651 if not res: 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
654 user, account_deletion_token = res
656 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
658 user.deleted_at = now()
659 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
660 user.undelete_token = urlsafe_secure_token()
662 session.flush()
664 notify(
665 session,
666 user_id=user.id,
667 topic_action=NotificationTopicAction.account_deletion__complete,
668 key="",
669 data=notification_data_pb2.AccountDeletionComplete(
670 undelete_token=user.undelete_token,
671 undelete_days=UNDELETE_DAYS,
672 ),
673 )
675 account_deletion_completions_counter.labels(user.gender).inc()
676 log_event(
677 context,
678 session,
679 "account.deletion_completed",
680 {"gender": user.gender},
681 _override_user_id=user.id,
682 )
684 return empty_pb2.Empty()
686 def RecoverAccount(
687 self, request: auth_pb2.RecoverAccountReq, context: CouchersContext, session: Session
688 ) -> empty_pb2.Empty:
689 """
690 Recovers a recently deleted account
691 """
692 user = session.execute(
693 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
694 ).scalar_one_or_none()
696 if not user: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true
697 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
699 user.deleted_at = None
700 user.undelete_token = None
701 user.undelete_until = None
703 notify(
704 session,
705 user_id=user.id,
706 topic_action=NotificationTopicAction.account_deletion__recovered,
707 key="",
708 )
710 account_recoveries_counter.labels(user.gender).inc()
711 log_event(
712 context,
713 session,
714 "account.recovered",
715 {"gender": user.gender},
716 _override_user_id=user.id,
717 )
719 return empty_pb2.Empty()
721 def Unsubscribe(
722 self, request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session
723 ) -> auth_pb2.UnsubscribeRes:
724 payload = decode_quick_link(request.payload, request.sig, context)
725 return auth_pb2.UnsubscribeRes(response=handle_unsubscribe(payload, context, session))
727 def AntiBot(self, request: auth_pb2.AntiBotReq, context: CouchersContext, session: Session) -> auth_pb2.AntiBotRes:
728 if not context.get_boolean_value("antibot_enabled", default=False):
729 return auth_pb2.AntiBotRes()
731 ip_address = context.get_header("x-couchers-real-ip")
732 user_agent = context.get_header("user-agent")
733 user_id = context.user_id if context.is_logged_in() else None
735 log = AntiBotLog(
736 token=request.token,
737 user_agent=user_agent,
738 ip_address=ip_address,
739 action=request.action,
740 user_id=user_id,
741 # placeholders: there is currently no provider assessing requests
742 score=0.0,
743 provider_data={},
744 )
746 session.add(log)
747 session.flush()
749 antibots_assessed_counter.labels(log.action).inc()
750 antibot_score_histogram.labels(log.action).observe(log.score)
752 if context.is_logged_in():
753 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
754 user.last_antibot = now()
756 return auth_pb2.AntiBotRes()
758 def AntiBotPolicy(
759 self, request: auth_pb2.AntiBotPolicyReq, context: CouchersContext, session: Session
760 ) -> auth_pb2.AntiBotPolicyRes:
761 if context.get_boolean_value("antibot_enabled", default=False):
762 if context.is_logged_in():
763 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
764 if now() - user.last_antibot > ANTIBOT_FREQ:
765 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
767 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
769 def GetInviteCodeInfo(
770 self, request: auth_pb2.GetInviteCodeInfoReq, context: CouchersContext, session: Session
771 ) -> auth_pb2.GetInviteCodeInfoRes:
772 invite = session.execute(
773 select(InviteCode).where(
774 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
775 )
776 ).scalar_one_or_none()
778 if not invite:
779 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found")
781 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
783 avatar_upload = get_avatar_upload(session, user)
785 return auth_pb2.GetInviteCodeInfoRes(
786 name=user.name,
787 username=user.username,
788 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None,
789 url=urls.invite_code_link(code=request.code),
790 )