Coverage for src/couchers/servicers/auth.py: 87%
287 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +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 errors, 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.servicers.account import abort_on_invalid_password, contributeoption2sql
39from couchers.servicers.api import hostingstatus2sql
40from couchers.sql import couchers_select as select
41from couchers.tasks import (
42 enforce_community_memberships_for_user,
43 maybe_send_contributor_form_email,
44 send_signup_email,
45)
46from couchers.utils import (
47 create_coordinate,
48 create_session_cookies,
49 is_valid_email,
50 is_valid_name,
51 is_valid_username,
52 minimum_allowed_birthdate,
53 now,
54 parse_date,
55 parse_session_cookie,
56)
57from proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
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(grpc.StatusCode.FAILED_PRECONDITION, errors.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(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
182 else:
183 if not request.flow_token:
184 # fresh signup
185 if not request.HasField("basic"):
186 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.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 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_TAKEN)
193 existing_flow = session.execute(
194 select(SignupFlow).where(SignupFlow.email == request.basic.email)
195 ).scalar_one_or_none()
196 if existing_flow:
197 send_signup_email(session, existing_flow)
198 session.commit()
199 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
201 if not is_valid_email(request.basic.email):
202 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
203 if not is_valid_name(request.basic.name):
204 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME)
206 flow_token = cookiesafe_secure_token()
208 invite_id = None
209 if request.basic.invite_code:
210 invite_id = session.execute(
211 select(InviteCode.id).where(
212 InviteCode.id == request.basic.invite_code,
213 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()),
214 )
215 ).scalar_one_or_none()
216 if not invite_id:
217 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_INVITE_CODE)
219 flow = SignupFlow(
220 flow_token=flow_token,
221 name=request.basic.name,
222 email=request.basic.email,
223 invite_code_id=invite_id,
224 )
225 session.add(flow)
226 session.flush()
227 signup_initiations_counter.inc()
228 else:
229 # not fresh signup
230 flow = session.execute(
231 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
232 ).scalar_one_or_none()
233 if not flow:
234 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
235 if request.HasField("basic"):
236 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_BASIC_FILLED)
238 # we've found and/or created a new flow, now sort out other parts
239 if request.HasField("account"):
240 if flow.account_is_filled:
241 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_ACCOUNT_FILLED)
243 # check username validity
244 if not is_valid_username(request.account.username):
245 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_USERNAME)
247 if not _username_available(session, request.account.username):
248 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.USERNAME_NOT_AVAILABLE)
250 abort_on_invalid_password(request.account.password, context)
251 hashed_password = hash_password(request.account.password)
253 birthdate = parse_date(request.account.birthdate)
254 if not birthdate or birthdate >= minimum_allowed_birthdate():
255 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.INVALID_BIRTHDATE)
257 if not request.account.hosting_status:
258 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.HOSTING_STATUS_REQUIRED)
260 if request.account.lat == 0 and request.account.lng == 0:
261 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE)
263 if not request.account.accept_tos:
264 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_TOS)
266 flow.username = request.account.username
267 flow.hashed_password = hashed_password
268 flow.birthdate = birthdate
269 flow.gender = request.account.gender
270 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
271 flow.city = request.account.city
272 flow.geom = create_coordinate(request.account.lat, request.account.lng)
273 flow.geom_radius = request.account.radius
274 flow.accepted_tos = TOS_VERSION
275 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
276 session.flush()
278 if request.HasField("feedback"):
279 if flow.filled_feedback:
280 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_FEEDBACK_FILLED)
281 form = request.feedback
283 flow.filled_feedback = True
284 flow.ideas = form.ideas
285 flow.features = form.features
286 flow.experience = form.experience
287 flow.contribute = contributeoption2sql[form.contribute]
288 flow.contribute_ways = form.contribute_ways
289 flow.expertise = form.expertise
290 session.flush()
292 if request.HasField("accept_community_guidelines"):
293 if not request.accept_community_guidelines.value:
294 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_COMMUNITY_GUIDELINES)
295 flow.accepted_community_guidelines = GUIDELINES_VERSION
296 session.flush()
298 # send verification email if needed
299 if not flow.email_sent or request.resend_verification_email:
300 send_signup_email(session, flow)
302 session.flush()
304 # finish the signup if done
305 if flow.is_completed:
306 user = User(
307 name=flow.name,
308 email=flow.email,
309 username=flow.username,
310 hashed_password=flow.hashed_password,
311 birthdate=flow.birthdate,
312 gender=flow.gender,
313 hosting_status=flow.hosting_status,
314 city=flow.city,
315 geom=flow.geom,
316 geom_radius=flow.geom_radius,
317 accepted_tos=flow.accepted_tos,
318 accepted_community_guidelines=flow.accepted_community_guidelines,
319 onboarding_emails_sent=1,
320 last_onboarding_email_sent=func.now(),
321 opt_out_of_newsletter=flow.opt_out_of_newsletter,
322 invite_code_id=flow.invite_code_id,
323 )
325 session.add(user)
327 if flow.filled_feedback:
328 form = ContributorForm(
329 user=user,
330 ideas=flow.ideas or None,
331 features=flow.features or None,
332 experience=flow.experience or None,
333 contribute=flow.contribute or None,
334 contribute_ways=flow.contribute_ways,
335 expertise=flow.expertise or None,
336 )
338 session.add(form)
340 user.filled_contributor_form = form.is_filled
342 maybe_send_contributor_form_email(session, form)
344 signup_duration_s = (now() - flow.created).total_seconds()
346 session.delete(flow)
347 session.commit()
349 enforce_community_memberships_for_user(session, user)
351 # sends onboarding email
352 notify(
353 session,
354 user_id=user.id,
355 topic_action="onboarding:reminder",
356 key="1",
357 )
359 signup_completions_counter.labels(flow.gender).inc()
360 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
362 create_session(context, session, user, False)
363 return auth_pb2.SignupFlowRes(
364 auth_res=_auth_res(user),
365 )
366 else:
367 return auth_pb2.SignupFlowRes(
368 flow_token=flow.flow_token,
369 need_account=not flow.account_is_filled,
370 need_feedback=False,
371 need_verify_email=not flow.email_verified,
372 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
373 )
375 def UsernameValid(self, request, context, session):
376 """
377 Runs a username availability and validity check.
378 """
379 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
381 def Authenticate(self, request, context, session):
382 """
383 Authenticates a classic password based login request.
385 request.user can be any of id/username/email
386 """
387 logger.debug(f"Logging in with {request.user=}, password=*******")
388 user = session.execute(
389 select(User).where_username_or_email(request.user).where(~User.is_deleted)
390 ).scalar_one_or_none()
391 if user:
392 logger.debug("Found user")
393 if verify_password(user.hashed_password, request.password):
394 logger.debug("Right password")
395 # correct password
396 create_session(context, session, user, request.remember_device)
397 return _auth_res(user)
398 else:
399 logger.debug("Wrong password")
400 # wrong password
401 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_PASSWORD)
402 else: # user not found
403 # check if this is an email and they tried to sign up but didn't complete
404 signup_flow = session.execute(
405 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow)
406 ).scalar_one_or_none()
407 if signup_flow:
408 send_signup_email(session, signup_flow)
409 session.commit()
410 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
411 logger.debug("Didn't find user")
412 context.abort(grpc.StatusCode.NOT_FOUND, errors.ACCOUNT_NOT_FOUND)
414 def GetAuthState(self, request, context, session):
415 if not context.is_logged_in():
416 return auth_pb2.GetAuthStateRes(logged_in=False)
417 else:
418 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
419 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
421 def Deauthenticate(self, request, context, session):
422 """
423 Removes an active cookie session.
424 """
425 token = parse_session_cookie(context.headers)
426 logger.info(f"Deauthenticate(token={token})")
428 # if we had a token, try to remove the session
429 if token:
430 delete_session(session, token)
432 # set the cookie to an empty string and expire immediately, should remove it from the browser
433 context.set_cookies(create_session_cookies("", "", now()))
435 return empty_pb2.Empty()
437 def ResetPassword(self, request, context, session):
438 """
439 If the user does not exist, do nothing.
441 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
442 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
444 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
445 """
446 user = session.execute(
447 select(User).where_username_or_email(request.user).where(~User.is_deleted)
448 ).scalar_one_or_none()
449 if user:
450 password_reset_token = PasswordResetToken(
451 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)
452 )
453 session.add(password_reset_token)
454 session.flush()
456 notify(
457 session,
458 user_id=user.id,
459 topic_action="password_reset:start",
460 data=notification_data_pb2.PasswordResetStart(
461 password_reset_token=password_reset_token.token,
462 ),
463 )
465 password_reset_initiations_counter.inc()
466 else: # user not found
467 logger.debug("Didn't find user")
469 return empty_pb2.Empty()
471 def CompletePasswordResetV2(self, request, context, session):
472 """
473 Completes the password reset: just clears the user's password
474 """
475 res = session.execute(
476 select(PasswordResetToken, User)
477 .join(User, User.id == PasswordResetToken.user_id)
478 .where(PasswordResetToken.token == request.password_reset_token)
479 .where(PasswordResetToken.is_valid)
480 ).one_or_none()
481 if res:
482 password_reset_token, user = res
483 abort_on_invalid_password(request.new_password, context)
484 user.hashed_password = hash_password(request.new_password)
485 session.delete(password_reset_token)
487 session.flush()
489 notify(
490 session,
491 user_id=user.id,
492 topic_action="password_reset:complete",
493 )
495 create_session(context, session, user, False)
496 password_reset_completions_counter.inc()
497 return _auth_res(user)
498 else:
499 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
501 def ConfirmChangeEmailV2(self, request, context, session):
502 user = session.execute(
503 select(User)
504 .where(User.new_email_token == request.change_email_token)
505 .where(User.new_email_token_created <= now())
506 .where(User.new_email_token_expiry >= now())
507 ).scalar_one_or_none()
509 if not user:
510 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
512 user.email = user.new_email
513 user.new_email = None
514 user.new_email_token = None
515 user.new_email_token_created = None
516 user.new_email_token_expiry = None
518 notify(
519 session,
520 user_id=user.id,
521 topic_action="email_address:verify",
522 )
524 return empty_pb2.Empty()
526 def ConfirmDeleteAccount(self, request, context, session):
527 """
528 Confirm account deletion using account delete token
529 """
530 res = session.execute(
531 select(User, AccountDeletionToken)
532 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
533 .where(AccountDeletionToken.token == request.token)
534 .where(AccountDeletionToken.is_valid)
535 ).one_or_none()
537 if not res:
538 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
540 user, account_deletion_token = res
542 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
544 user.is_deleted = True
545 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
546 user.undelete_token = urlsafe_secure_token()
548 session.flush()
550 notify(
551 session,
552 user_id=user.id,
553 topic_action="account_deletion:complete",
554 data=notification_data_pb2.AccountDeletionComplete(
555 undelete_token=user.undelete_token,
556 undelete_days=UNDELETE_DAYS,
557 ),
558 )
560 account_deletion_completions_counter.labels(user.gender).inc()
562 return empty_pb2.Empty()
564 def RecoverAccount(self, request, context, session):
565 """
566 Recovers a recently deleted account
567 """
568 user = session.execute(
569 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
570 ).scalar_one_or_none()
572 if not user:
573 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
575 user.is_deleted = False
576 user.undelete_token = None
577 user.undelete_until = None
579 notify(
580 session,
581 user_id=user.id,
582 topic_action="account_deletion:recovered",
583 )
585 account_recoveries_counter.labels(user.gender).inc()
587 return empty_pb2.Empty()
589 def Unsubscribe(self, request, context, session):
590 return auth_pb2.UnsubscribeRes(response=respond_quick_link(request, context, session))
592 def AntiBot(self, request, context, session):
593 if not config["RECAPTHCA_ENABLED"]:
594 return auth_pb2.AntiBotRes()
596 ip_address = context.headers.get("x-couchers-real-ip")
597 user_agent = context.headers.get("user-agent")
599 log = AntiBotLog(
600 token=request.token,
601 user_agent=user_agent,
602 ip_address=ip_address,
603 action=request.action,
604 user_id=context.user_id if context.is_logged_in() else None,
605 )
607 resp = requests.post(
608 f"https://recaptchaenterprise.googleapis.com/v1/projects/{config['RECAPTHCA_PROJECT_ID']}/assessments?key={config['RECAPTHCA_API_KEY']}",
609 json={
610 "event": {
611 "token": log.token,
612 "siteKey": config["RECAPTHCA_SITE_KEY"],
613 "userAgent": log.user_agent,
614 "userIpAddress": log.ip_address,
615 "expectedAction": log.action,
616 "userInfo": {"accountId": str(log.user_id) if log.user_id else None},
617 }
618 },
619 )
621 resp.raise_for_status()
623 log.score = resp.json()["riskAnalysis"]["score"]
624 log.provider_data = resp.json()
626 session.add(log)
628 session.flush()
630 recaptchas_assessed_counter.labels(log.action).inc()
631 recaptcha_score_histogram.labels(log.action).observe(log.score)
633 if context.is_logged_in():
634 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
635 user.last_antibot = now()
637 return auth_pb2.AntiBotRes()
639 def AntiBotPolicy(self, request, context, session):
640 if config["RECAPTHCA_ENABLED"]:
641 if context.is_logged_in():
642 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
643 if now() - user.last_antibot > ANTIBOT_FREQ:
644 return auth_pb2.AntiBotPolicyRes(should_antibot=True)
646 return auth_pb2.AntiBotPolicyRes(should_antibot=False)
648 def GetInviteCodeInfo(self, request, context, session):
649 invite = session.execute(
650 select(InviteCode).where(
651 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now())
652 )
653 ).scalar_one_or_none()
655 if not invite:
656 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVITE_CODE_NOT_FOUND)
658 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one()
660 return auth_pb2.GetInviteCodeInfoRes(
661 name=user.name,
662 username=user.username,
663 avatar_url=user.avatar.thumbnail_url if user.avatar else None,
664 url=urls.invite_code_link(code=request.code),
665 )