Coverage for src / couchers / servicers / auth.py: 85%
300 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-13 12:05 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-13 12:05 +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.metrics import (
18 account_deletion_completions_counter,
19 account_recoveries_counter,
20 logins_counter,
21 password_reset_completions_counter,
22 password_reset_initiations_counter,
23 recaptcha_score_histogram,
24 recaptchas_assessed_counter,
25 signup_completions_counter,
26 signup_initiations_counter,
27 signup_time_histogram,
28)
29from couchers.models import (
30 AccountDeletionToken,
31 AntiBotLog,
32 ContributorForm,
33 InviteCode,
34 PasswordResetToken,
35 PhotoGallery,
36 SignupFlow,
37 User,
38 UserSession,
39)
40from couchers.models.notifications import NotificationTopicAction
41from couchers.notifications.notify import notify
42from couchers.notifications.quick_links import respond_quick_link
43from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
44from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
45from couchers.servicers.api import hostingstatus2sql
46from couchers.sql import username_or_email
47from couchers.tasks import (
48 enforce_community_memberships_for_user,
49 maybe_send_contributor_form_email,
50 send_signup_email,
51)
52from couchers.utils import (
53 create_coordinate,
54 create_session_cookies,
55 is_geom,
56 is_valid_email,
57 is_valid_name,
58 is_valid_username,
59 minimum_allowed_birthdate,
60 not_none,
61 now,
62 parse_date,
63 parse_session_cookie,
64)
66logger = logging.getLogger(__name__)
69def _auth_res(user: User) -> auth_pb2.AuthRes:
70 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
73def create_session(
74 context: CouchersContext,
75 session: Session,
76 user: User,
77 long_lived: bool,
78 is_api_key: bool = False,
79 duration: timedelta | None = None,
80 set_cookie: bool = True,
81) -> tuple[str, datetime]:
82 """
83 Creates a session for the given user and returns the token and expiry.
85 You need to give an active DB session as nested sessions don't really
86 work here due to the active User object.
88 Will abort the API calling context if the user is banned from logging in.
90 You can set the cookie on the client (if `is_api_key=False`) with
92 ```py3
93 token, expiry = create_session(...)
94 ```
95 """
96 if user.is_banned:
97 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended")
99 # just double-check
100 assert not user.is_deleted
102 token = cookiesafe_secure_token()
104 user_session = UserSession(
105 token=token,
106 user_id=user.id,
107 long_lived=long_lived,
108 ip_address=cast(str | None, context.headers.get("x-couchers-real-ip")),
109 user_agent=cast(str | None, context.headers.get("user-agent")),
110 is_api_key=is_api_key,
111 )
112 if duration:
113 user_session.expiry = func.now() + duration
115 session.add(user_session)
116 session.commit()
118 logger.debug(f"Handing out {token=} to {user=}")
120 if set_cookie:
121 context.set_cookies(create_session_cookies(token, user.id, user_session.expiry))
123 logins_counter.labels(user.gender).inc()
125 return token, user_session.expiry
128def delete_session(session: Session, token: str) -> bool:
129 """
130 Deletes the given session (practically logging the user out)
132 Returns True if the session was found, False otherwise.
133 """
134 user_session = session.execute(
135 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
136 ).scalar_one_or_none()
137 if user_session: 137 ↛ 142line 137 didn't jump to line 142 because the condition on line 137 was always true
138 user_session.deleted = func.now()
139 session.commit()
140 return True
141 else:
142 return False
145def _username_available(session: Session, username: str) -> bool:
146 """
147 Checks if the given username adheres to our rules and isn't taken already.
148 """
149 logger.debug(f"Checking if {username=} is valid")
150 if not is_valid_username(username):
151 return False
152 for phrase in BANNED_USERNAME_PHRASES:
153 if phrase.lower() in username.lower():
154 return False
155 # check for existing user with that username
156 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
157 # check for started signup with that username
158 signup_exists = (
159 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
160 )
161 # return False if user exists, True otherwise
162 return not user_exists and not signup_exists
165class Auth(auth_pb2_grpc.AuthServicer):
166 """
167 The Auth servicer.
169 This class services the Auth service/API.
170 """
172 def SignupFlow(
173 self, request: auth_pb2.SignupFlowReq, context: CouchersContext, session: Session
174 ) -> auth_pb2.SignupFlowRes:
175 if request.email_token:
176 # the email token can either be for verification or just to find an existing signup
177 flow = session.execute(
178 select(SignupFlow)
179 .where(SignupFlow.email_verified == False)
180 .where(SignupFlow.email_token == request.email_token)
181 .where(SignupFlow.token_is_valid)
182 ).scalar_one_or_none()
183 if flow: 183 ↛ 192line 183 didn't jump to line 192 because the condition on line 183 was always true
184 # find flow by email verification token and mark it as verified
185 flow.email_verified = True
186 flow.email_token = None
187 flow.email_token_expiry = None
189 session.flush()
190 else:
191 # just try to find the flow by flow token, no verification is done
192 flow = session.execute(
193 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
194 ).scalar_one_or_none()
195 if not flow:
196 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
197 else:
198 if not request.flow_token:
199 # fresh signup
200 if not request.HasField("basic"): 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed")
202 # TODO: unique across both tables
203 existing_user = session.execute(
204 select(User).where(User.email == request.basic.email)
205 ).scalar_one_or_none()
206 if existing_user:
207 if not existing_user.is_visible:
208 context.abort_with_error_code(
209 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used"
210 )
211 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
212 existing_flow = session.execute(
213 select(SignupFlow).where(SignupFlow.email == request.basic.email)
214 ).scalar_one_or_none()
215 if existing_flow:
216 send_signup_email(session, existing_flow)
217 session.commit()
218 context.abort_with_error_code(
219 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup"
220 )
222 if not is_valid_email(request.basic.email):
223 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
224 if not is_valid_name(request.basic.name):
225 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
227 flow_token = cookiesafe_secure_token()
229 invite_id = None
230 if request.basic.invite_code:
231 invite_id = session.execute(
232 select(InviteCode.id).where(
233 InviteCode.id == request.basic.invite_code,
234 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
235 )
236 ).scalar_one_or_none()
237 if not invite_id: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code")
240 flow = SignupFlow(
241 flow_token=flow_token,
242 name=request.basic.name,
243 email=request.basic.email,
244 invite_code_id=invite_id,
245 )
246 session.add(flow)
247 session.flush()
248 signup_initiations_counter.inc()
249 else:
250 # not fresh signup
251 flow = session.execute(
252 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
253 ).scalar_one_or_none()
254 if not flow: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
256 if request.HasField("basic"): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled")
259 # we've found and/or created a new flow, now sort out other parts
260 if request.HasField("account"):
261 if flow.account_is_filled: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled")
264 # check username validity
265 if not is_valid_username(request.account.username):
266 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username")
268 if not _username_available(session, request.account.username):
269 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available")
271 abort_on_invalid_password(request.account.password, context)
272 hashed_password = hash_password(request.account.password)
274 birthdate = parse_date(request.account.birthdate)
275 if not birthdate or birthdate >= minimum_allowed_birthdate():
276 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate")
278 if not request.account.hosting_status:
279 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required")
281 if request.account.lat == 0 and request.account.lng == 0:
282 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
284 if not request.account.accept_tos: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_tos")
287 flow.username = request.account.username
288 flow.hashed_password = hashed_password
289 flow.birthdate = birthdate
290 flow.gender = request.account.gender
291 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
292 flow.city = request.account.city
293 flow.geom = create_coordinate(request.account.lat, request.account.lng)
294 flow.geom_radius = request.account.radius
295 flow.accepted_tos = TOS_VERSION
296 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
297 session.flush()
299 if request.HasField("feedback"):
300 if flow.filled_feedback: 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.FAILED_PRECONDITION, "signup_flow_feedback_filled")
302 form = request.feedback
304 flow.filled_feedback = True
305 flow.ideas = form.ideas
306 flow.features = form.features
307 flow.experience = form.experience
308 flow.contribute = contributeoption2sql[form.contribute]
309 flow.contribute_ways = form.contribute_ways # type: ignore[assignment]
310 flow.expertise = form.expertise
311 session.flush()
313 if request.HasField("accept_community_guidelines"):
314 if not request.accept_community_guidelines.value: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines")
316 flow.accepted_community_guidelines = GUIDELINES_VERSION
317 session.flush()
319 # send verification email if needed
320 if not flow.email_sent or request.resend_verification_email:
321 send_signup_email(session, flow)
323 session.flush()
325 # finish the signup if done
326 if flow.is_completed:
327 user = User(
328 name=flow.name,
329 email=flow.email,
330 username=not_none(flow.username),
331 hashed_password=not_none(flow.hashed_password),
332 birthdate=not_none(flow.birthdate),
333 gender=not_none(flow.gender),
334 hosting_status=not_none(flow.hosting_status),
335 city=not_none(flow.city),
336 geom=is_geom(flow.geom),
337 geom_radius=not_none(flow.geom_radius),
338 accepted_tos=not_none(flow.accepted_tos),
339 last_onboarding_email_sent=func.now(),
340 invite_code_id=flow.invite_code_id,
341 )
343 user.accepted_community_guidelines = flow.accepted_community_guidelines
344 user.onboarding_emails_sent = 1
345 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter)
347 session.add(user)
348 session.flush()
350 # Create a profile gallery for the user
351 profile_gallery = PhotoGallery(owner_user_id=user.id)
352 session.add(profile_gallery)
353 session.flush()
354 user.profile_gallery_id = profile_gallery.id
356 if flow.filled_feedback:
357 form_ = ContributorForm(
358 user_id=user.id,
359 ideas=flow.ideas or None,
360 features=flow.features or None,
361 experience=flow.experience or None,
362 contribute=flow.contribute or None,
363 contribute_ways=not_none(flow.contribute_ways),
364 expertise=flow.expertise or None,
365 )
367 session.add(form_)
369 user.filled_contributor_form = form_.is_filled
371 maybe_send_contributor_form_email(session, form_)
373 signup_duration_s = (now() - flow.created).total_seconds()
375 session.delete(flow)
376 session.commit()
378 enforce_community_memberships_for_user(session, user)
380 # sends onboarding email
381 notify(
382 session,
383 user_id=user.id,
384 topic_action=NotificationTopicAction.onboarding__reminder,
385 key="1",
386 )
388 signup_completions_counter.labels(flow.gender).inc()
389 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
391 create_session(context, session, user, False)
392 return auth_pb2.SignupFlowRes(
393 auth_res=_auth_res(user),
394 )
395 else:
396 return auth_pb2.SignupFlowRes(
397 flow_token=flow.flow_token,
398 need_account=not flow.account_is_filled,
399 need_feedback=False,
400 need_verify_email=not flow.email_verified,
401 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
402 )
404 def UsernameValid(
405 self, request: auth_pb2.UsernameValidReq, context: CouchersContext, session: Session
406 ) -> auth_pb2.UsernameValidRes:
407 """
408 Runs a username availability and validity check.
409 """
410 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
412 def Authenticate(self, request: auth_pb2.AuthReq, context: CouchersContext, session: Session) -> auth_pb2.AuthRes:
413 """
414 Authenticates a classic password-based login request.
416 request.user can be any of id/username/email
417 """
418 logger.debug(f"Logging in with {request.user=}, password=*******")
419 user = session.execute(
420 select(User).where(username_or_email(request.user)).where(~User.is_deleted)
421 ).scalar_one_or_none()
422 if user:
423 logger.debug("Found user")
424 if verify_password(user.hashed_password, request.password):
425 logger.debug("Right password")
426 # correct password
427 create_session(context, session, user, request.remember_device)
428 return _auth_res(user)
429 else:
430 logger.debug("Wrong password")
431 # wrong password
432 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password")
433 else: # user not found
434 # check if this is an email and they tried to sign up but didn't complete
435 signup_flow = session.execute(
436 select(SignupFlow).where(username_or_email(request.user, table=SignupFlow))
437 ).scalar_one_or_none()
438 if signup_flow:
439 send_signup_email(session, signup_flow)
440 session.commit()
441 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup")
442 logger.debug("Didn't find user")
443 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found")
445 def GetAuthState(
446 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
447 ) -> auth_pb2.GetAuthStateRes:
448 if not context.is_logged_in():
449 return auth_pb2.GetAuthStateRes(logged_in=False)
450 else:
451 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
452 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
454 def Deauthenticate(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> empty_pb2.Empty:
455 """
456 Removes an active cookie session.
457 """
458 token = parse_session_cookie(context.headers)
459 logger.info(f"Deauthenticate(token={token})")
461 # if we had a token, try to remove the session
462 if token: 462 ↛ 466line 462 didn't jump to line 466 because the condition on line 462 was always true
463 delete_session(session, token)
465 # set the cookie to an empty string and expire immediately, should remove it from the browser
466 context.set_cookies(create_session_cookies("", "", now()))
468 return empty_pb2.Empty()
470 def ResetPassword(
471 self, request: auth_pb2.ResetPasswordReq, context: CouchersContext, session: Session
472 ) -> empty_pb2.Empty:
473 """
474 If the user does not exist, do nothing.
476 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
477 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
479 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
480 """
481 user = session.execute(
482 select(User).where(username_or_email(request.user)).where(~User.is_deleted)
483 ).scalar_one_or_none()
484 if user:
485 password_reset_token = PasswordResetToken(
486 token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)
487 )
488 session.add(password_reset_token)
489 session.flush()
491 notify(
492 session,
493 user_id=user.id,
494 topic_action=NotificationTopicAction.password_reset__start,
495 key="",
496 data=notification_data_pb2.PasswordResetStart(
497 password_reset_token=password_reset_token.token,
498 ),
499 )
501 password_reset_initiations_counter.inc()
502 else: # user not found
503 logger.debug("Didn't find user")
505 return empty_pb2.Empty()
507 def CompletePasswordResetV2(
508 self, request: auth_pb2.CompletePasswordResetV2Req, context: CouchersContext, session: Session
509 ) -> auth_pb2.AuthRes:
510 """
511 Completes the password reset: just clears the user's password
512 """
513 res = session.execute(
514 select(PasswordResetToken, User)
515 .join(User, User.id == PasswordResetToken.user_id)
516 .where(PasswordResetToken.token == request.password_reset_token)
517 .where(PasswordResetToken.is_valid)
518 ).one_or_none()
519 if res:
520 password_reset_token, user = res
521 abort_on_invalid_password(request.new_password, context)
522 user.hashed_password = hash_password(request.new_password)
523 session.delete(password_reset_token)
525 session.flush()
527 notify(
528 session,
529 user_id=user.id,
530 topic_action=NotificationTopicAction.password_reset__complete,
531 key="",
532 )
534 create_session(context, session, user, False)
535 password_reset_completions_counter.inc()
536 return _auth_res(user)
537 else:
538 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
540 def ConfirmChangeEmailV2(
541 self, request: auth_pb2.ConfirmChangeEmailV2Req, context: CouchersContext, session: Session
542 ) -> empty_pb2.Empty:
543 user = session.execute(
544 select(User)
545 .where(User.new_email_token == request.change_email_token)
546 .where(User.new_email_token_created <= now())
547 .where(User.new_email_token_expiry >= now())
548 ).scalar_one_or_none()
550 if not user:
551 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
553 user.email = not_none(user.new_email)
554 user.new_email = None
555 user.new_email_token = None
556 user.new_email_token_created = None
557 user.new_email_token_expiry = None
559 notify(
560 session,
561 user_id=user.id,
562 topic_action=NotificationTopicAction.email_address__verify,
563 key="",
564 )
566 return empty_pb2.Empty()
568 def ConfirmDeleteAccount(
569 self, request: auth_pb2.ConfirmDeleteAccountReq, context: CouchersContext, session: Session
570 ) -> empty_pb2.Empty:
571 """
572 Confirm account deletion using account delete token
573 """
574 res = session.execute(
575 select(User, AccountDeletionToken)
576 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
577 .where(AccountDeletionToken.token == request.token)
578 .where(AccountDeletionToken.is_valid)
579 ).one_or_none()
581 if not res: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
584 user, account_deletion_token = res
586 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
588 user.is_deleted = True
589 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
590 user.undelete_token = urlsafe_secure_token()
592 session.flush()
594 notify(
595 session,
596 user_id=user.id,
597 topic_action=NotificationTopicAction.account_deletion__complete,
598 key="",
599 data=notification_data_pb2.AccountDeletionComplete(
600 undelete_token=user.undelete_token,
601 undelete_days=UNDELETE_DAYS,
602 ),
603 )
605 account_deletion_completions_counter.labels(user.gender).inc()
607 return empty_pb2.Empty()
609 def RecoverAccount(
610 self, request: auth_pb2.RecoverAccountReq, context: CouchersContext, session: Session
611 ) -> empty_pb2.Empty:
612 """
613 Recovers a recently deleted account
614 """
615 user = session.execute(
616 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
617 ).scalar_one_or_none()
619 if not user: 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true
620 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
622 user.is_deleted = False
623 user.undelete_token = None
624 user.undelete_until = None
626 notify(
627 session,
628 user_id=user.id,
629 topic_action=NotificationTopicAction.account_deletion__recovered,
630 key="",
631 )
633 account_recoveries_counter.labels(user.gender).inc()
635 return empty_pb2.Empty()
637 def Unsubscribe(
638 self, request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session
639 ) -> auth_pb2.UnsubscribeRes:
640 return auth_pb2.UnsubscribeRes(response=respond_quick_link(request, context, session))
642 def AntiBot(self, request: auth_pb2.AntiBotReq, context: CouchersContext, session: Session) -> auth_pb2.AntiBotRes:
643 if not config["RECAPTHCA_ENABLED"]:
644 return auth_pb2.AntiBotRes()
646 ip_address = cast(str | None, context.headers.get("x-couchers-real-ip"))
647 user_agent = cast(str | None, context.headers.get("user-agent"))
648 user_id = context.user_id if context.is_logged_in() else None
650 resp = requests.post(
651 f"https://recaptchaenterprise.googleapis.com/v1/projects/{config['RECAPTHCA_PROJECT_ID']}/assessments?key={config['RECAPTHCA_API_KEY']}",
652 json={
653 "event": {
654 "token": request.token,
655 "siteKey": config["RECAPTHCA_SITE_KEY"],
656 "userAgent": user_agent,
657 "userIpAddress": ip_address,
658 "expectedAction": request.action,
659 "userInfo": {"accountId": str(user_id) if user_id else None},
660 }
661 },
662 )
664 resp.raise_for_status()
666 log = AntiBotLog(
667 token=request.token,
668 user_agent=user_agent,
669 ip_address=ip_address,
670 action=request.action,
671 user_id=user_id,
672 score=resp.json()["riskAnalysis"]["score"],
673 provider_data=resp.json(),
674 )
676 session.add(log)
677 session.flush()
679 recaptchas_assessed_counter.labels(log.action).inc()
680 recaptcha_score_histogram.labels(log.action).observe(log.score)
682 if context.is_logged_in():
683 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
684 user.last_antibot = now()
686 return auth_pb2.AntiBotRes()
688 def AntiBotPolicy(
689 self, request: auth_pb2.AntiBotPolicyReq, context: CouchersContext, session: Session
690 ) -> auth_pb2.AntiBotPolicyRes:
691 if config["RECAPTHCA_ENABLED"]:
692 if context.is_logged_in():
693 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
694 if now() - user.last_antibot > ANTIBOT_FREQ:
695 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
697 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
699 def GetInviteCodeInfo(
700 self, request: auth_pb2.GetInviteCodeInfoReq, context: CouchersContext, session: Session
701 ) -> auth_pb2.GetInviteCodeInfoRes:
702 invite = session.execute(
703 select(InviteCode).where(
704 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
705 )
706 ).scalar_one_or_none()
708 if not invite:
709 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found")
711 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
713 return auth_pb2.GetInviteCodeInfoRes(
714 name=user.name,
715 username=user.username,
716 avatar_url=user.avatar.thumbnail_url if user.avatar else None,
717 url=urls.invite_code_link(code=request.code),
718 )