Coverage for src/couchers/servicers/auth.py: 87%
289 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-04 01:57 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-04 01:57 +0000
1import logging
2from datetime import timedelta
4import grpc
5import requests
6from google.protobuf import empty_pb2
7from sqlalchemy.sql import delete, func, or_
9from couchers import urls
10from couchers.config import config
11from couchers.constants import ANTIBOT_FREQ, BANNED_USERNAME_PHRASES, GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS
12from couchers.context import CouchersContext
13from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password
14from couchers.metrics import (
15 account_deletion_completions_counter,
16 account_recoveries_counter,
17 logins_counter,
18 password_reset_completions_counter,
19 password_reset_initiations_counter,
20 recaptcha_score_histogram,
21 recaptchas_assessed_counter,
22 signup_completions_counter,
23 signup_initiations_counter,
24 signup_time_histogram,
25)
26from couchers.models import (
27 AccountDeletionToken,
28 AntiBotLog,
29 ContributorForm,
30 InviteCode,
31 PasswordResetToken,
32 SignupFlow,
33 User,
34 UserSession,
35)
36from couchers.notifications.notify import notify
37from couchers.notifications.quick_links import respond_quick_link
38from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
39from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
40from couchers.servicers.api import hostingstatus2sql
41from couchers.sql import couchers_select as select
42from couchers.tasks import (
43 enforce_community_memberships_for_user,
44 maybe_send_contributor_form_email,
45 send_signup_email,
46)
47from couchers.utils import (
48 create_coordinate,
49 create_session_cookies,
50 is_valid_email,
51 is_valid_name,
52 is_valid_username,
53 minimum_allowed_birthdate,
54 now,
55 parse_date,
56 parse_session_cookie,
57)
59logger = logging.getLogger(__name__)
62def _auth_res(user):
63 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
66def create_session(
67 context: CouchersContext, session, user, long_lived, is_api_key=False, duration=None, set_cookie=True
68):
69 """
70 Creates a session for the given user and returns the token and expiry.
72 You need to give an active DB session as nested sessions don't really
73 work here due to the active User object.
75 Will abort the API calling context if the user is banned from logging in.
77 You can set the cookie on the client (if `is_api_key=False`) with
79 ```py3
80 token, expiry = create_session(...)
81 ```
82 """
83 if user.is_banned:
84 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended")
86 # just double-check
87 assert not user.is_deleted
89 token = cookiesafe_secure_token()
91 user_session = UserSession(
92 token=token,
93 user=user,
94 long_lived=long_lived,
95 ip_address=context.headers.get("x-couchers-real-ip"),
96 user_agent=context.headers.get("user-agent"),
97 is_api_key=is_api_key,
98 )
99 if duration:
100 user_session.expiry = func.now() + duration
102 session.add(user_session)
103 session.commit()
105 logger.debug(f"Handing out {token=} to {user=}")
107 if set_cookie:
108 context.set_cookies(create_session_cookies(token, user.id, user_session.expiry))
110 logins_counter.labels(user.gender).inc()
112 return token, user_session.expiry
115def delete_session(session, token):
116 """
117 Deletes the given session (practically logging the user out)
119 Returns True if the session was found, False otherwise.
120 """
121 user_session = session.execute(
122 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
123 ).scalar_one_or_none()
124 if user_session:
125 user_session.deleted = func.now()
126 session.commit()
127 return True
128 else:
129 return False
132def _username_available(session, username):
133 """
134 Checks if the given username adheres to our rules and isn't taken already.
135 """
136 logger.debug(f"Checking if {username=} is valid")
137 if not is_valid_username(username):
138 return False
139 for phrase in BANNED_USERNAME_PHRASES:
140 if phrase.lower() in username.lower():
141 return False
142 # check for existing user with that username
143 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
144 # check for started signup with that username
145 signup_exists = (
146 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
147 )
148 # return False if user exists, True otherwise
149 return not user_exists and not signup_exists
152class Auth(auth_pb2_grpc.AuthServicer):
153 """
154 The Auth servicer.
156 This class services the Auth service/API.
157 """
159 def SignupFlow(self, request, context, session):
160 if request.email_token:
161 # the email token can either be for verification or just to find an existing signup
162 flow = session.execute(
163 select(SignupFlow)
164 .where(SignupFlow.email_verified == False)
165 .where(SignupFlow.email_token == request.email_token)
166 .where(SignupFlow.token_is_valid)
167 ).scalar_one_or_none()
168 if flow:
169 # find flow by email verification token and mark it as verified
170 flow.email_verified = True
171 flow.email_token = None
172 flow.email_token_expiry = None
174 session.flush()
175 else:
176 # just try to find the flow by flow token, no verification is done
177 flow = session.execute(
178 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
179 ).scalar_one_or_none()
180 if not flow:
181 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
182 else:
183 if not request.flow_token:
184 # fresh signup
185 if not request.HasField("basic"):
186 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed")
187 # TODO: unique across both tables
188 existing_user = session.execute(
189 select(User).where(User.email == request.basic.email)
190 ).scalar_one_or_none()
191 if existing_user:
192 if not existing_user.is_visible:
193 context.abort_with_error_code(
194 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used"
195 )
196 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
197 existing_flow = session.execute(
198 select(SignupFlow).where(SignupFlow.email == request.basic.email)
199 ).scalar_one_or_none()
200 if existing_flow:
201 send_signup_email(session, existing_flow)
202 session.commit()
203 context.abort_with_error_code(
204 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup"
205 )
207 if not is_valid_email(request.basic.email):
208 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
209 if not is_valid_name(request.basic.name):
210 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name")
212 flow_token = cookiesafe_secure_token()
214 invite_id = None
215 if request.basic.invite_code:
216 invite_id = session.execute(
217 select(InviteCode.id).where(
218 InviteCode.id == request.basic.invite_code,
219 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
220 )
221 ).scalar_one_or_none()
222 if not invite_id:
223 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code")
225 flow = SignupFlow(
226 flow_token=flow_token,
227 name=request.basic.name,
228 email=request.basic.email,
229 invite_code_id=invite_id,
230 )
231 session.add(flow)
232 session.flush()
233 signup_initiations_counter.inc()
234 else:
235 # not fresh signup
236 flow = session.execute(
237 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
238 ).scalar_one_or_none()
239 if not flow:
240 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
241 if request.HasField("basic"):
242 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled")
244 # we've found and/or created a new flow, now sort out other parts
245 if request.HasField("account"):
246 if flow.account_is_filled:
247 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled")
249 # check username validity
250 if not is_valid_username(request.account.username):
251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username")
253 if not _username_available(session, request.account.username):
254 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available")
256 abort_on_invalid_password(request.account.password, context)
257 hashed_password = hash_password(request.account.password)
259 birthdate = parse_date(request.account.birthdate)
260 if not birthdate or birthdate >= minimum_allowed_birthdate():
261 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate")
263 if not request.account.hosting_status:
264 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required")
266 if request.account.lat == 0 and request.account.lng == 0:
267 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate")
269 if not request.account.accept_tos:
270 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_tos")
272 flow.username = request.account.username
273 flow.hashed_password = hashed_password
274 flow.birthdate = birthdate
275 flow.gender = request.account.gender
276 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
277 flow.city = request.account.city
278 flow.geom = create_coordinate(request.account.lat, request.account.lng)
279 flow.geom_radius = request.account.radius
280 flow.accepted_tos = TOS_VERSION
281 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
282 session.flush()
284 if request.HasField("feedback"):
285 if flow.filled_feedback:
286 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_feedback_filled")
287 form = request.feedback
289 flow.filled_feedback = True
290 flow.ideas = form.ideas
291 flow.features = form.features
292 flow.experience = form.experience
293 flow.contribute = contributeoption2sql[form.contribute]
294 flow.contribute_ways = form.contribute_ways
295 flow.expertise = form.expertise
296 session.flush()
298 if request.HasField("accept_community_guidelines"):
299 if not request.accept_community_guidelines.value:
300 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines")
301 flow.accepted_community_guidelines = GUIDELINES_VERSION
302 session.flush()
304 # send verification email if needed
305 if not flow.email_sent or request.resend_verification_email:
306 send_signup_email(session, flow)
308 session.flush()
310 # finish the signup if done
311 if flow.is_completed:
312 user = User(
313 name=flow.name,
314 email=flow.email,
315 username=flow.username,
316 hashed_password=flow.hashed_password,
317 birthdate=flow.birthdate,
318 gender=flow.gender,
319 hosting_status=flow.hosting_status,
320 city=flow.city,
321 geom=flow.geom,
322 geom_radius=flow.geom_radius,
323 accepted_tos=flow.accepted_tos,
324 accepted_community_guidelines=flow.accepted_community_guidelines,
325 onboarding_emails_sent=1,
326 last_onboarding_email_sent=func.now(),
327 opt_out_of_newsletter=flow.opt_out_of_newsletter,
328 invite_code_id=flow.invite_code_id,
329 )
331 session.add(user)
333 if flow.filled_feedback:
334 form = ContributorForm(
335 user=user,
336 ideas=flow.ideas or None,
337 features=flow.features or None,
338 experience=flow.experience or None,
339 contribute=flow.contribute or None,
340 contribute_ways=flow.contribute_ways,
341 expertise=flow.expertise or None,
342 )
344 session.add(form)
346 user.filled_contributor_form = form.is_filled
348 maybe_send_contributor_form_email(session, form)
350 signup_duration_s = (now() - flow.created).total_seconds()
352 session.delete(flow)
353 session.commit()
355 enforce_community_memberships_for_user(session, user)
357 # sends onboarding email
358 notify(
359 session,
360 user_id=user.id,
361 topic_action="onboarding:reminder",
362 key="1",
363 )
365 signup_completions_counter.labels(flow.gender).inc()
366 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
368 create_session(context, session, user, False)
369 return auth_pb2.SignupFlowRes(
370 auth_res=_auth_res(user),
371 )
372 else:
373 return auth_pb2.SignupFlowRes(
374 flow_token=flow.flow_token,
375 need_account=not flow.account_is_filled,
376 need_feedback=False,
377 need_verify_email=not flow.email_verified,
378 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
379 )
381 def UsernameValid(self, request, context, session):
382 """
383 Runs a username availability and validity check.
384 """
385 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
387 def Authenticate(self, request, context, session):
388 """
389 Authenticates a classic password-based login request.
391 request.user can be any of id/username/email
392 """
393 logger.debug(f"Logging in with {request.user=}, password=*******")
394 user = session.execute(
395 select(User).where_username_or_email(request.user).where(~User.is_deleted)
396 ).scalar_one_or_none()
397 if user:
398 logger.debug("Found user")
399 if verify_password(user.hashed_password, request.password):
400 logger.debug("Right password")
401 # correct password
402 create_session(context, session, user, request.remember_device)
403 return _auth_res(user)
404 else:
405 logger.debug("Wrong password")
406 # wrong password
407 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password")
408 else: # user not found
409 # check if this is an email and they tried to sign up but didn't complete
410 signup_flow = session.execute(
411 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow)
412 ).scalar_one_or_none()
413 if signup_flow:
414 send_signup_email(session, signup_flow)
415 session.commit()
416 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup")
417 logger.debug("Didn't find user")
418 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found")
420 def GetAuthState(self, request, context, session):
421 if not context.is_logged_in():
422 return auth_pb2.GetAuthStateRes(logged_in=False)
423 else:
424 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
425 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
427 def Deauthenticate(self, request, context, session):
428 """
429 Removes an active cookie session.
430 """
431 token = parse_session_cookie(context.headers)
432 logger.info(f"Deauthenticate(token={token})")
434 # if we had a token, try to remove the session
435 if token:
436 delete_session(session, token)
438 # set the cookie to an empty string and expire immediately, should remove it from the browser
439 context.set_cookies(create_session_cookies("", "", now()))
441 return empty_pb2.Empty()
443 def ResetPassword(self, request, context, session):
444 """
445 If the user does not exist, do nothing.
447 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
448 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
450 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
451 """
452 user = session.execute(
453 select(User).where_username_or_email(request.user).where(~User.is_deleted)
454 ).scalar_one_or_none()
455 if user:
456 password_reset_token = PasswordResetToken(
457 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)
458 )
459 session.add(password_reset_token)
460 session.flush()
462 notify(
463 session,
464 user_id=user.id,
465 topic_action="password_reset:start",
466 data=notification_data_pb2.PasswordResetStart(
467 password_reset_token=password_reset_token.token,
468 ),
469 )
471 password_reset_initiations_counter.inc()
472 else: # user not found
473 logger.debug("Didn't find user")
475 return empty_pb2.Empty()
477 def CompletePasswordResetV2(self, request, context, session):
478 """
479 Completes the password reset: just clears the user's password
480 """
481 res = session.execute(
482 select(PasswordResetToken, User)
483 .join(User, User.id == PasswordResetToken.user_id)
484 .where(PasswordResetToken.token == request.password_reset_token)
485 .where(PasswordResetToken.is_valid)
486 ).one_or_none()
487 if res:
488 password_reset_token, user = res
489 abort_on_invalid_password(request.new_password, context)
490 user.hashed_password = hash_password(request.new_password)
491 session.delete(password_reset_token)
493 session.flush()
495 notify(
496 session,
497 user_id=user.id,
498 topic_action="password_reset:complete",
499 )
501 create_session(context, session, user, False)
502 password_reset_completions_counter.inc()
503 return _auth_res(user)
504 else:
505 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
507 def ConfirmChangeEmailV2(self, request, context, session):
508 user = session.execute(
509 select(User)
510 .where(User.new_email_token == request.change_email_token)
511 .where(User.new_email_token_created <= now())
512 .where(User.new_email_token_expiry >= now())
513 ).scalar_one_or_none()
515 if not user:
516 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
518 user.email = user.new_email
519 user.new_email = None
520 user.new_email_token = None
521 user.new_email_token_created = None
522 user.new_email_token_expiry = None
524 notify(
525 session,
526 user_id=user.id,
527 topic_action="email_address:verify",
528 )
530 return empty_pb2.Empty()
532 def ConfirmDeleteAccount(self, request, context, session):
533 """
534 Confirm account deletion using account delete token
535 """
536 res = session.execute(
537 select(User, AccountDeletionToken)
538 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
539 .where(AccountDeletionToken.token == request.token)
540 .where(AccountDeletionToken.is_valid)
541 ).one_or_none()
543 if not res:
544 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
546 user, account_deletion_token = res
548 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
550 user.is_deleted = True
551 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
552 user.undelete_token = urlsafe_secure_token()
554 session.flush()
556 notify(
557 session,
558 user_id=user.id,
559 topic_action="account_deletion:complete",
560 data=notification_data_pb2.AccountDeletionComplete(
561 undelete_token=user.undelete_token,
562 undelete_days=UNDELETE_DAYS,
563 ),
564 )
566 account_deletion_completions_counter.labels(user.gender).inc()
568 return empty_pb2.Empty()
570 def RecoverAccount(self, request, context, session):
571 """
572 Recovers a recently deleted account
573 """
574 user = session.execute(
575 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
576 ).scalar_one_or_none()
578 if not user:
579 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
581 user.is_deleted = False
582 user.undelete_token = None
583 user.undelete_until = None
585 notify(
586 session,
587 user_id=user.id,
588 topic_action="account_deletion:recovered",
589 )
591 account_recoveries_counter.labels(user.gender).inc()
593 return empty_pb2.Empty()
595 def Unsubscribe(self, request, context, session):
596 return auth_pb2.UnsubscribeRes(response=respond_quick_link(request, context, session))
598 def AntiBot(self, request, context, session):
599 if not config["RECAPTHCA_ENABLED"]:
600 return auth_pb2.AntiBotRes()
602 ip_address = context.headers.get("x-couchers-real-ip")
603 user_agent = context.headers.get("user-agent")
605 log = AntiBotLog(
606 token=request.token,
607 user_agent=user_agent,
608 ip_address=ip_address,
609 action=request.action,
610 user_id=context.user_id if context.is_logged_in() else None,
611 )
613 resp = requests.post(
614 f"https://recaptchaenterprise.googleapis.com/v1/projects/{config['RECAPTHCA_PROJECT_ID']}/assessments?key={config['RECAPTHCA_API_KEY']}",
615 json={
616 "event": {
617 "token": log.token,
618 "siteKey": config["RECAPTHCA_SITE_KEY"],
619 "userAgent": log.user_agent,
620 "userIpAddress": log.ip_address,
621 "expectedAction": log.action,
622 "userInfo": {"accountId": str(log.user_id) if log.user_id else None},
623 }
624 },
625 )
627 resp.raise_for_status()
629 log.score = resp.json()["riskAnalysis"]["score"]
630 log.provider_data = resp.json()
632 session.add(log)
634 session.flush()
636 recaptchas_assessed_counter.labels(log.action).inc()
637 recaptcha_score_histogram.labels(log.action).observe(log.score)
639 if context.is_logged_in():
640 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
641 user.last_antibot = now()
643 return auth_pb2.AntiBotRes()
645 def AntiBotPolicy(self, request, context, session):
646 if config["RECAPTHCA_ENABLED"]:
647 if context.is_logged_in():
648 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
649 if now() - user.last_antibot > ANTIBOT_FREQ:
650 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
652 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
654 def GetInviteCodeInfo(self, request, context, session):
655 invite = session.execute(
656 select(InviteCode).where(
657 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
658 )
659 ).scalar_one_or_none()
661 if not invite:
662 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found")
664 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
666 return auth_pb2.GetInviteCodeInfoRes(
667 name=user.name,
668 username=user.username,
669 avatar_url=user.avatar.thumbnail_url if user.avatar else None,
670 url=urls.invite_code_link(code=request.code),
671 )