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