Coverage for app / backend / src / couchers / servicers / auth.py: 86%
319 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1import logging
2from datetime import datetime, timedelta
3from typing import cast
5import grpc
6import requests
7from google.protobuf import empty_pb2
8from sqlalchemy import select
9from sqlalchemy.orm import Session
10from sqlalchemy.sql import delete, func, or_
12from couchers import urls
13from couchers.config import config
14from couchers.constants import ANTIBOT_FREQ, BANNED_USERNAME_PHRASES, GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS
15from couchers.context import CouchersContext
16from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password
17from couchers.event_log import log_event
18from couchers.metrics import (
19 account_deletion_completions_counter,
20 account_recoveries_counter,
21 logins_counter,
22 password_reset_completions_counter,
23 password_reset_initiations_counter,
24 recaptcha_score_histogram,
25 recaptchas_assessed_counter,
26 signup_completions_counter,
27 signup_initiations_counter,
28 signup_time_histogram,
29)
30from couchers.models import (
31 AccountDeletionToken,
32 AntiBotLog,
33 ContributorForm,
34 InviteCode,
35 PasswordResetToken,
36 PhotoGallery,
37 SignupFlow,
38 User,
39 UserSession,
40)
41from couchers.models.notifications import NotificationTopicAction
42from couchers.models.uploads import get_avatar_upload
43from couchers.notifications.notify import notify
44from couchers.notifications.quick_links import respond_quick_link
45from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
46from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
47from couchers.servicers.api import hostingstatus2sql
48from couchers.sql import username_or_email
49from couchers.tasks import (
50 enforce_community_memberships_for_user,
51 maybe_send_contributor_form_email,
52 send_signup_email,
53)
54from couchers.utils import (
55 create_coordinate,
56 create_session_cookies,
57 is_geom,
58 is_valid_email,
59 is_valid_name,
60 is_valid_username,
61 minimum_allowed_birthdate,
62 not_none,
63 now,
64 parse_date,
65 parse_session_cookie,
66)
68logger = logging.getLogger(__name__)
71def _auth_res(user: User) -> auth_pb2.AuthRes:
72 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
75def create_session(
76 context: CouchersContext,
77 session: Session,
78 user: User,
79 long_lived: bool,
80 is_api_key: bool = False,
81 duration: timedelta | None = None,
82 set_cookie: bool = True,
83) -> tuple[str, datetime]:
84 """
85 Creates a session for the given user and returns the token and expiry.
87 You need to give an active DB session as nested sessions don't really
88 work here due to the active User object.
90 Will abort the API calling context if the user is banned from logging in.
92 You can set the cookie on the client (if `is_api_key=False`) with
94 ```py3
95 token, expiry = create_session(...)
96 ```
97 """
98 if user.banned_at is not None:
99 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended")
101 # just double-check
102 assert user.deleted_at is None
104 token = cookiesafe_secure_token()
106 user_session = UserSession(
107 token=token,
108 user_id=user.id,
109 long_lived=long_lived,
110 ip_address=cast(str | None, context.headers.get("x-couchers-real-ip")),
111 user_agent=cast(str | None, context.headers.get("user-agent")),
112 is_api_key=is_api_key,
113 )
114 if duration:
115 user_session.expiry = func.now() + duration
117 session.add(user_session)
118 session.commit()
120 logger.debug(f"Handing out {token=} to {user=}")
122 if set_cookie:
123 context.set_cookies(create_session_cookies(token, user.id, user_session.expiry))
125 logins_counter.labels(user.gender).inc()
127 return token, user_session.expiry
130def delete_session(session: Session, token: str) -> bool:
131 """
132 Deletes the given session (practically logging the user out)
134 Returns True if the session was found, False otherwise.
135 """
136 user_session = session.execute(
137 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
138 ).scalar_one_or_none()
139 if user_session: 139 ↛ 144line 139 didn't jump to line 144 because the condition on line 139 was always true
140 user_session.deleted = func.now()
141 session.commit()
142 return True
143 else:
144 return False
147def _username_available(session: Session, username: str) -> bool:
148 """
149 Checks if the given username adheres to our rules and isn't taken already.
150 """
151 logger.debug(f"Checking if {username=} is valid")
152 if not is_valid_username(username):
153 return False
154 for phrase in BANNED_USERNAME_PHRASES:
155 if phrase.lower() in username.lower():
156 return False
157 # check for existing user with that username
158 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
159 # check for started signup with that username
160 signup_exists = (
161 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
162 )
163 # return False if user exists, True otherwise
164 return not user_exists and not signup_exists
167class Auth(auth_pb2_grpc.AuthServicer):
168 """
169 The Auth servicer.
171 This class services the Auth service/API.
172 """
174 def SignupFlow(
175 self, request: auth_pb2.SignupFlowReq, context: CouchersContext, session: Session
176 ) -> auth_pb2.SignupFlowRes:
177 if request.email_token:
178 # the email token can either be for verification or just to find an existing signup
179 flow = session.execute(
180 select(SignupFlow)
181 .where(SignupFlow.email_verified == False)
182 .where(SignupFlow.email_token == request.email_token)
183 .where(SignupFlow.token_is_valid)
184 ).scalar_one_or_none()
185 if flow: 185 ↛ 194line 185 didn't jump to line 194 because the condition on line 185 was always true
186 # find flow by email verification token and mark it as verified
187 flow.email_verified = True
188 flow.email_token = None
189 flow.email_token_expiry = None
191 session.flush()
192 else:
193 # just try to find the flow by flow token, no verification is done
194 flow = session.execute(
195 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
196 ).scalar_one_or_none()
197 if not flow:
198 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
199 else:
200 if not request.flow_token:
201 # fresh signup
202 if not request.HasField("basic"): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed")
204 # TODO: unique across both tables
205 existing_user = session.execute(
206 select(User).where(User.email == request.basic.email)
207 ).scalar_one_or_none()
208 if existing_user:
209 if not existing_user.is_visible:
210 context.abort_with_error_code(
211 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used"
212 )
213 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
214 existing_flow = session.execute(
215 select(SignupFlow).where(SignupFlow.email == request.basic.email)
216 ).scalar_one_or_none()
217 if existing_flow:
218 send_signup_email(session, existing_flow)
219 session.commit()
220 context.abort_with_error_code(
221 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup"
222 )
224 if not is_valid_email(request.basic.email):
225 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
226 if not is_valid_name(request.basic.name):
227 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
229 flow_token = cookiesafe_secure_token()
231 invite_id = None
232 if request.basic.invite_code:
233 invite_id = session.execute(
234 select(InviteCode.id).where(
235 InviteCode.id == request.basic.invite_code,
236 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
237 )
238 ).scalar_one_or_none()
239 if not invite_id: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code")
242 flow = SignupFlow(
243 flow_token=flow_token,
244 name=request.basic.name,
245 email=request.basic.email,
246 invite_code_id=invite_id,
247 )
248 session.add(flow)
249 session.flush()
250 signup_initiations_counter.inc()
251 log_event(context, session, "account.signup_initiated", {"has_invite_code": invite_id is not None})
252 else:
253 # not fresh signup
254 flow = session.execute(
255 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
256 ).scalar_one_or_none()
257 if not flow: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
259 if request.HasField("basic"): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled")
262 # we've found and/or created a new flow, now sort out other parts
263 if request.HasField("account"):
264 if flow.account_is_filled: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled")
267 # check username validity
268 if not is_valid_username(request.account.username):
269 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username")
271 if not _username_available(session, request.account.username):
272 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available")
274 abort_on_invalid_password(request.account.password, context)
275 hashed_password = hash_password(request.account.password)
277 birthdate = parse_date(request.account.birthdate)
278 if not birthdate or birthdate >= minimum_allowed_birthdate():
279 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate")
281 if not request.account.hosting_status:
282 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required")
284 if request.account.lat == 0 and request.account.lng == 0:
285 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
287 if not request.account.accept_tos: 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, "must_accept_tos")
290 flow.username = request.account.username
291 flow.hashed_password = hashed_password
292 flow.birthdate = birthdate
293 flow.gender = request.account.gender
294 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
295 flow.city = request.account.city
296 flow.geom = create_coordinate(request.account.lat, request.account.lng)
297 flow.geom_radius = request.account.radius
298 flow.accepted_tos = TOS_VERSION
299 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
300 session.flush()
302 if request.HasField("feedback"):
303 if flow.filled_feedback: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_feedback_filled")
305 form = request.feedback
307 flow.filled_feedback = True
308 flow.ideas = form.ideas
309 flow.features = form.features
310 flow.experience = form.experience
311 flow.contribute = contributeoption2sql[form.contribute]
312 flow.contribute_ways = form.contribute_ways # type: ignore[assignment]
313 flow.expertise = form.expertise
314 session.flush()
316 if request.HasField("motivations"):
317 if flow.filled_motivations:
318 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_motivations_filled")
320 flow.filled_motivations = True
321 flow.heard_about_couchers = request.motivations.heard_about_couchers or None
322 flow.signup_motivations = list(request.motivations.motivations)
323 session.flush()
325 if request.HasField("accept_community_guidelines"):
326 if not request.accept_community_guidelines.value: 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines")
328 flow.accepted_community_guidelines = GUIDELINES_VERSION
329 session.flush()
331 # send verification email if needed
332 if not flow.email_sent or request.resend_verification_email:
333 send_signup_email(session, flow)
335 session.flush()
337 # finish the signup if done
338 if flow.is_completed:
339 user = User(
340 name=flow.name,
341 email=flow.email,
342 username=not_none(flow.username),
343 hashed_password=not_none(flow.hashed_password),
344 birthdate=not_none(flow.birthdate),
345 gender=not_none(flow.gender),
346 hosting_status=not_none(flow.hosting_status),
347 city=not_none(flow.city),
348 geom=is_geom(flow.geom),
349 geom_radius=not_none(flow.geom_radius),
350 accepted_tos=not_none(flow.accepted_tos),
351 last_onboarding_email_sent=func.now(),
352 invite_code_id=flow.invite_code_id,
353 heard_about_couchers=flow.heard_about_couchers,
354 signup_motivations=flow.signup_motivations if flow.filled_motivations else None,
355 )
357 user.accepted_community_guidelines = flow.accepted_community_guidelines
358 user.onboarding_emails_sent = 1
359 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter)
361 session.add(user)
362 session.flush()
364 # Create a profile gallery for the user
365 profile_gallery = PhotoGallery(owner_user_id=user.id)
366 session.add(profile_gallery)
367 session.flush()
368 user.profile_gallery_id = profile_gallery.id
370 if flow.filled_feedback:
371 form_ = ContributorForm(
372 user_id=user.id,
373 ideas=flow.ideas or None,
374 features=flow.features or None,
375 experience=flow.experience or None,
376 contribute=flow.contribute or None,
377 contribute_ways=not_none(flow.contribute_ways),
378 expertise=flow.expertise or None,
379 )
381 session.add(form_)
383 user.filled_contributor_form = form_.is_filled
385 maybe_send_contributor_form_email(session, form_)
387 signup_duration_s = (now() - flow.created).total_seconds()
389 session.delete(flow)
390 session.commit()
392 enforce_community_memberships_for_user(session, user)
394 # sends onboarding email
395 notify(
396 session,
397 user_id=user.id,
398 topic_action=NotificationTopicAction.onboarding__reminder,
399 key="1",
400 )
402 signup_completions_counter.labels(flow.gender).inc()
403 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
404 log_event(
405 context,
406 session,
407 "account.signup_completed",
408 {
409 "gender": flow.gender,
410 "signup_duration_s": signup_duration_s,
411 "hosting_status": str(flow.hosting_status),
412 "city": flow.city,
413 "has_invite_code": flow.invite_code_id is not None,
414 "filled_contributor_form": user.filled_contributor_form,
415 },
416 _override_user_id=user.id,
417 )
419 create_session(context, session, user, False)
420 return auth_pb2.SignupFlowRes(
421 auth_res=_auth_res(user),
422 )
423 else:
424 return auth_pb2.SignupFlowRes(
425 flow_token=flow.flow_token,
426 need_account=not flow.account_is_filled,
427 need_feedback=False,
428 need_verify_email=not flow.email_verified,
429 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
430 need_motivations=not flow.filled_motivations,
431 )
433 def UsernameValid(
434 self, request: auth_pb2.UsernameValidReq, context: CouchersContext, session: Session
435 ) -> auth_pb2.UsernameValidRes:
436 """
437 Runs a username availability and validity check.
438 """
439 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
441 def Authenticate(self, request: auth_pb2.AuthReq, context: CouchersContext, session: Session) -> auth_pb2.AuthRes:
442 """
443 Authenticates a classic password-based login request.
445 request.user can be any of id/username/email
446 """
447 logger.debug(f"Logging in with {request.user=}, password=*******")
448 user = session.execute(
449 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
450 ).scalar_one_or_none()
451 if user:
452 logger.debug("Found user")
453 if verify_password(user.hashed_password, request.password):
454 logger.debug("Right password")
455 # correct password
456 create_session(context, session, user, request.remember_device)
457 log_event(
458 context,
459 session,
460 "account.login",
461 {"gender": user.gender, "remember_device": request.remember_device},
462 _override_user_id=user.id,
463 )
464 return _auth_res(user)
465 else:
466 logger.debug("Wrong password")
467 # wrong password
468 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password")
469 else: # user not found
470 # check if this is an email and they tried to sign up but didn't complete
471 signup_flow = session.execute(
472 select(SignupFlow).where(username_or_email(request.user, table=SignupFlow))
473 ).scalar_one_or_none()
474 if signup_flow:
475 send_signup_email(session, signup_flow)
476 session.commit()
477 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup")
478 logger.debug("Didn't find user")
479 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found")
481 def GetAuthState(
482 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
483 ) -> auth_pb2.GetAuthStateRes:
484 if not context.is_logged_in():
485 return auth_pb2.GetAuthStateRes(logged_in=False)
486 else:
487 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
488 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
490 def Deauthenticate(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> empty_pb2.Empty:
491 """
492 Removes an active cookie session.
493 """
494 token = parse_session_cookie(context.headers)
495 logger.info(f"Deauthenticate(token={token})")
497 # if we had a token, try to remove the session
498 if token: 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was always true
499 delete_session(session, token)
501 log_event(context, session, "account.logout", {})
503 # set the cookie to an empty string and expire immediately, should remove it from the browser
504 context.set_cookies(create_session_cookies("", "", now()))
506 return empty_pb2.Empty()
508 def ResetPassword(
509 self, request: auth_pb2.ResetPasswordReq, context: CouchersContext, session: Session
510 ) -> empty_pb2.Empty:
511 """
512 If the user does not exist, do nothing.
514 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
515 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
517 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
518 """
519 user = session.execute(
520 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None))
521 ).scalar_one_or_none()
522 if user:
523 password_reset_token = PasswordResetToken(
524 token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)
525 )
526 session.add(password_reset_token)
527 session.flush()
529 notify(
530 session,
531 user_id=user.id,
532 topic_action=NotificationTopicAction.password_reset__start,
533 key="",
534 data=notification_data_pb2.PasswordResetStart(
535 password_reset_token=password_reset_token.token,
536 ),
537 )
539 password_reset_initiations_counter.inc()
540 log_event(
541 context,
542 session,
543 "account.password_reset_initiated",
544 {},
545 _override_user_id=user.id,
546 )
547 else: # user not found
548 logger.debug("Didn't find user")
550 return empty_pb2.Empty()
552 def CompletePasswordResetV2(
553 self, request: auth_pb2.CompletePasswordResetV2Req, context: CouchersContext, session: Session
554 ) -> auth_pb2.AuthRes:
555 """
556 Completes the password reset: just clears the user's password
557 """
558 res = session.execute(
559 select(PasswordResetToken, User)
560 .join(User, User.id == PasswordResetToken.user_id)
561 .where(PasswordResetToken.token == request.password_reset_token)
562 .where(PasswordResetToken.is_valid)
563 ).one_or_none()
564 if res:
565 password_reset_token, user = res
566 abort_on_invalid_password(request.new_password, context)
567 user.hashed_password = hash_password(request.new_password)
568 session.delete(password_reset_token)
570 session.flush()
572 notify(
573 session,
574 user_id=user.id,
575 topic_action=NotificationTopicAction.password_reset__complete,
576 key="",
577 )
579 create_session(context, session, user, False)
580 password_reset_completions_counter.inc()
581 log_event(
582 context,
583 session,
584 "account.password_reset_completed",
585 {},
586 _override_user_id=user.id,
587 )
588 return _auth_res(user)
589 else:
590 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
592 def ConfirmChangeEmailV2(
593 self, request: auth_pb2.ConfirmChangeEmailV2Req, context: CouchersContext, session: Session
594 ) -> empty_pb2.Empty:
595 user = session.execute(
596 select(User)
597 .where(User.new_email_token == request.change_email_token)
598 .where(User.new_email_token_created <= now())
599 .where(User.new_email_token_expiry >= now())
600 ).scalar_one_or_none()
602 if not user:
603 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
605 user.email = not_none(user.new_email)
606 user.new_email = None
607 user.new_email_token = None
608 user.new_email_token_created = None
609 user.new_email_token_expiry = None
611 notify(
612 session,
613 user_id=user.id,
614 topic_action=NotificationTopicAction.email_address__verify,
615 key="",
616 )
618 log_event(context, session, "account.email_confirmed", {}, _override_user_id=user.id)
620 return empty_pb2.Empty()
622 def ConfirmDeleteAccount(
623 self, request: auth_pb2.ConfirmDeleteAccountReq, context: CouchersContext, session: Session
624 ) -> empty_pb2.Empty:
625 """
626 Confirm account deletion using account delete token
627 """
628 res = session.execute(
629 select(User, AccountDeletionToken)
630 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
631 .where(AccountDeletionToken.token == request.token)
632 .where(AccountDeletionToken.is_valid)
633 ).one_or_none()
635 if not res: 635 ↛ 636line 635 didn't jump to line 636 because the condition on line 635 was never true
636 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
638 user, account_deletion_token = res
640 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
642 user.deleted_at = now()
643 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
644 user.undelete_token = urlsafe_secure_token()
646 session.flush()
648 notify(
649 session,
650 user_id=user.id,
651 topic_action=NotificationTopicAction.account_deletion__complete,
652 key="",
653 data=notification_data_pb2.AccountDeletionComplete(
654 undelete_token=user.undelete_token,
655 undelete_days=UNDELETE_DAYS,
656 ),
657 )
659 account_deletion_completions_counter.labels(user.gender).inc()
660 log_event(
661 context,
662 session,
663 "account.deletion_completed",
664 {"gender": user.gender},
665 _override_user_id=user.id,
666 )
668 return empty_pb2.Empty()
670 def RecoverAccount(
671 self, request: auth_pb2.RecoverAccountReq, context: CouchersContext, session: Session
672 ) -> empty_pb2.Empty:
673 """
674 Recovers a recently deleted account
675 """
676 user = session.execute(
677 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
678 ).scalar_one_or_none()
680 if not user: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true
681 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
683 user.deleted_at = None
684 user.undelete_token = None
685 user.undelete_until = None
687 notify(
688 session,
689 user_id=user.id,
690 topic_action=NotificationTopicAction.account_deletion__recovered,
691 key="",
692 )
694 account_recoveries_counter.labels(user.gender).inc()
695 log_event(
696 context,
697 session,
698 "account.recovered",
699 {"gender": user.gender},
700 _override_user_id=user.id,
701 )
703 return empty_pb2.Empty()
705 def Unsubscribe(
706 self, request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session
707 ) -> auth_pb2.UnsubscribeRes:
708 return auth_pb2.UnsubscribeRes(response=respond_quick_link(request, context, session))
710 def AntiBot(self, request: auth_pb2.AntiBotReq, context: CouchersContext, session: Session) -> auth_pb2.AntiBotRes:
711 if not config["RECAPTHCA_ENABLED"]:
712 return auth_pb2.AntiBotRes()
714 ip_address = cast(str | None, context.headers.get("x-couchers-real-ip"))
715 user_agent = cast(str | None, context.headers.get("user-agent"))
716 user_id = context.user_id if context.is_logged_in() else None
718 resp = requests.post(
719 f"https://recaptchaenterprise.googleapis.com/v1/projects/{config['RECAPTHCA_PROJECT_ID']}/assessments?key={config['RECAPTHCA_API_KEY']}",
720 json={
721 "event": {
722 "token": request.token,
723 "siteKey": config["RECAPTHCA_SITE_KEY"],
724 "userAgent": user_agent,
725 "userIpAddress": ip_address,
726 "expectedAction": request.action,
727 "userInfo": {"accountId": str(user_id) if user_id else None},
728 }
729 },
730 )
732 resp.raise_for_status()
734 log = AntiBotLog(
735 token=request.token,
736 user_agent=user_agent,
737 ip_address=ip_address,
738 action=request.action,
739 user_id=user_id,
740 score=resp.json()["riskAnalysis"]["score"],
741 provider_data=resp.json(),
742 )
744 session.add(log)
745 session.flush()
747 recaptchas_assessed_counter.labels(log.action).inc()
748 recaptcha_score_histogram.labels(log.action).observe(log.score)
750 if context.is_logged_in():
751 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
752 user.last_antibot = now()
754 return auth_pb2.AntiBotRes()
756 def AntiBotPolicy(
757 self, request: auth_pb2.AntiBotPolicyReq, context: CouchersContext, session: Session
758 ) -> auth_pb2.AntiBotPolicyRes:
759 if config["RECAPTHCA_ENABLED"]:
760 if context.is_logged_in():
761 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
762 if now() - user.last_antibot > ANTIBOT_FREQ:
763 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
765 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
767 def GetInviteCodeInfo(
768 self, request: auth_pb2.GetInviteCodeInfoReq, context: CouchersContext, session: Session
769 ) -> auth_pb2.GetInviteCodeInfoRes:
770 invite = session.execute(
771 select(InviteCode).where(
772 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
773 )
774 ).scalar_one_or_none()
776 if not invite:
777 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found")
779 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
781 avatar_upload = get_avatar_upload(session, user)
783 return auth_pb2.GetInviteCodeInfoRes(
784 name=user.name,
785 username=user.username,
786 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None,
787 url=urls.invite_code_link(code=request.code),
788 )