Coverage for src/couchers/servicers/auth.py: 95%
246 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-06-01 15:07 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-06-01 15:07 +0000
1import logging
2from datetime import timedelta
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy.sql import delete, func
8from couchers import errors
9from couchers.constants import GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS
10from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password
11from couchers.metrics import (
12 account_deletion_completions_counter,
13 account_recoveries_counter,
14 logins_counter,
15 password_reset_completions_counter,
16 password_reset_initiations_counter,
17 signup_completions_counter,
18 signup_initiations_counter,
19 signup_time_histogram,
20)
21from couchers.models import AccountDeletionToken, ContributorForm, PasswordResetToken, SignupFlow, User, UserSession
22from couchers.notifications.notify import notify
23from couchers.notifications.unsubscribe import unsubscribe
24from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
25from couchers.servicers.api import hostingstatus2sql
26from couchers.sql import couchers_select as select
27from couchers.tasks import (
28 enforce_community_memberships_for_user,
29 maybe_send_contributor_form_email,
30 send_signup_email,
31)
32from couchers.utils import (
33 create_coordinate,
34 create_session_cookies,
35 is_valid_email,
36 is_valid_name,
37 is_valid_username,
38 minimum_allowed_birthdate,
39 now,
40 parse_date,
41 parse_session_cookie,
42)
43from proto import auth_pb2, auth_pb2_grpc, notification_data_pb2
45logger = logging.getLogger(__name__)
48def _auth_res(user):
49 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
52def create_session(context, session, user, long_lived, is_api_key=False, duration=None, set_cookie=True):
53 """
54 Creates a session for the given user and returns the token and expiry.
56 You need to give an active DB session as nested sessions don't really
57 work here due to the active User object.
59 Will abort the API calling context if the user is banned from logging in.
61 You can set the cookie on the client (if `is_api_key=False`) with
63 ```py3
64 token, expiry = create_session(...)
65 ```
66 """
67 if user.is_banned:
68 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.ACCOUNT_SUSPENDED)
70 # just double check
71 assert not user.is_deleted
73 token = cookiesafe_secure_token()
75 headers = dict(context.invocation_metadata())
77 user_session = UserSession(
78 token=token,
79 user=user,
80 long_lived=long_lived,
81 ip_address=headers.get("x-couchers-real-ip"),
82 user_agent=headers.get("user-agent"),
83 is_api_key=is_api_key,
84 )
85 if duration:
86 user_session.expiry = func.now() + duration
88 session.add(user_session)
89 session.commit()
91 logger.debug(f"Handing out {token=} to {user=}")
93 if set_cookie:
94 context.send_initial_metadata(
95 [("set-cookie", cookie) for cookie in create_session_cookies(token, user.id, user_session.expiry)]
96 )
98 logins_counter.labels(user.gender).inc()
100 return token, user_session.expiry
103def delete_session(session, token):
104 """
105 Deletes the given session (practically logging the user out)
107 Returns True if the session was found, False otherwise.
108 """
109 user_session = session.execute(
110 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
111 ).scalar_one_or_none()
112 if user_session:
113 user_session.deleted = func.now()
114 session.commit()
115 return True
116 else:
117 return False
120def _username_available(session, username):
121 """
122 Checks if the given username adheres to our rules and isn't taken already.
123 """
124 logger.debug(f"Checking if {username=} is valid")
125 if not is_valid_username(username):
126 return False
127 # check for existing user with that username
128 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
129 # check for started signup with that username
130 signup_exists = (
131 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None
132 )
133 # return False if user exists, True otherwise
134 return not user_exists and not signup_exists
137class Auth(auth_pb2_grpc.AuthServicer):
138 """
139 The Auth servicer.
141 This class services the Auth service/API.
142 """
144 def SignupFlow(self, request, context, session):
145 if request.email_token:
146 # the email token can either be for verification or just to find an existing signup
147 flow = session.execute(
148 select(SignupFlow)
149 .where(SignupFlow.email_verified == False)
150 .where(SignupFlow.email_token == request.email_token)
151 .where(SignupFlow.token_is_valid)
152 ).scalar_one_or_none()
153 if flow:
154 # find flow by email verification token and mark it as verified
155 flow.email_verified = True
156 flow.email_token = None
157 flow.email_token_expiry = None
159 session.flush()
160 else:
161 # just try to find the flow by flow token, no verification is done
162 flow = session.execute(
163 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
164 ).scalar_one_or_none()
165 if not flow:
166 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
167 else:
168 if not request.flow_token:
169 # fresh signup
170 if not request.HasField("basic"):
171 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.SIGNUP_FLOW_BASIC_NEEDED)
172 # TODO: unique across both tables
173 existing_user = session.execute(
174 select(User).where(User.email == request.basic.email)
175 ).scalar_one_or_none()
176 if existing_user:
177 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_TAKEN)
178 existing_flow = session.execute(
179 select(SignupFlow).where(SignupFlow.email == request.basic.email)
180 ).scalar_one_or_none()
181 if existing_flow:
182 send_signup_email(session, existing_flow)
183 session.commit()
184 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
186 if not is_valid_email(request.basic.email):
187 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
188 if not is_valid_name(request.basic.name):
189 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME)
191 flow_token = cookiesafe_secure_token()
193 flow = SignupFlow(
194 flow_token=flow_token,
195 name=request.basic.name,
196 email=request.basic.email,
197 )
198 session.add(flow)
199 session.flush()
200 signup_initiations_counter.inc()
201 else:
202 # not fresh signup
203 flow = session.execute(
204 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
205 ).scalar_one_or_none()
206 if not flow:
207 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
208 if request.HasField("basic"):
209 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_BASIC_FILLED)
211 # we've found and/or created a new flow, now sort out other parts
212 if request.HasField("account"):
213 if flow.account_is_filled:
214 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_ACCOUNT_FILLED)
216 # check username validity
217 if not is_valid_username(request.account.username):
218 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_USERNAME)
220 if not _username_available(session, request.account.username):
221 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.USERNAME_NOT_AVAILABLE)
223 abort_on_invalid_password(request.account.password, context)
224 hashed_password = hash_password(request.account.password)
226 birthdate = parse_date(request.account.birthdate)
227 if not birthdate or birthdate >= minimum_allowed_birthdate():
228 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.INVALID_BIRTHDATE)
230 if not request.account.hosting_status:
231 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.HOSTING_STATUS_REQUIRED)
233 if request.account.lat == 0 and request.account.lng == 0:
234 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE)
236 if not request.account.accept_tos:
237 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_TOS)
239 flow.username = request.account.username
240 flow.hashed_password = hashed_password
241 flow.birthdate = birthdate
242 flow.gender = request.account.gender
243 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
244 flow.city = request.account.city
245 flow.geom = create_coordinate(request.account.lat, request.account.lng)
246 flow.geom_radius = request.account.radius
247 flow.accepted_tos = TOS_VERSION
248 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter
249 session.flush()
251 if request.HasField("feedback"):
252 if flow.filled_feedback:
253 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_FEEDBACK_FILLED)
254 form = request.feedback
256 flow.filled_feedback = True
257 flow.ideas = form.ideas
258 flow.features = form.features
259 flow.experience = form.experience
260 flow.contribute = contributeoption2sql[form.contribute]
261 flow.contribute_ways = form.contribute_ways
262 flow.expertise = form.expertise
263 session.flush()
265 if request.HasField("accept_community_guidelines"):
266 if not request.accept_community_guidelines.value:
267 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_COMMUNITY_GUIDELINES)
268 flow.accepted_community_guidelines = GUIDELINES_VERSION
269 session.flush()
271 # send verification email if needed
272 if not flow.email_sent or request.resend_verification_email:
273 send_signup_email(session, flow)
275 session.flush()
277 # finish the signup if done
278 if flow.is_completed:
279 user = User(
280 name=flow.name,
281 email=flow.email,
282 username=flow.username,
283 hashed_password=flow.hashed_password,
284 birthdate=flow.birthdate,
285 gender=flow.gender,
286 hosting_status=flow.hosting_status,
287 city=flow.city,
288 geom=flow.geom,
289 geom_radius=flow.geom_radius,
290 accepted_tos=flow.accepted_tos,
291 accepted_community_guidelines=flow.accepted_community_guidelines,
292 onboarding_emails_sent=1,
293 last_onboarding_email_sent=func.now(),
294 opt_out_of_newsletter=flow.opt_out_of_newsletter,
295 )
297 session.add(user)
299 if flow.filled_feedback:
300 form = ContributorForm(
301 user=user,
302 ideas=flow.ideas or None,
303 features=flow.features or None,
304 experience=flow.experience or None,
305 contribute=flow.contribute or None,
306 contribute_ways=flow.contribute_ways,
307 expertise=flow.expertise or None,
308 )
310 session.add(form)
312 user.filled_contributor_form = form.is_filled
314 maybe_send_contributor_form_email(session, form)
316 signup_duration_s = (now() - flow.created).total_seconds()
318 session.delete(flow)
319 session.commit()
321 enforce_community_memberships_for_user(session, user)
323 # sends onboarding email
324 notify(
325 session,
326 user_id=user.id,
327 topic_action="onboarding:reminder",
328 key="1",
329 )
331 signup_completions_counter.labels(flow.gender).inc()
332 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
334 create_session(context, session, user, False)
335 return auth_pb2.SignupFlowRes(
336 auth_res=_auth_res(user),
337 )
338 else:
339 return auth_pb2.SignupFlowRes(
340 flow_token=flow.flow_token,
341 need_account=not flow.account_is_filled,
342 need_feedback=False,
343 need_verify_email=not flow.email_verified,
344 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
345 )
347 def UsernameValid(self, request, context, session):
348 """
349 Runs a username availability and validity check.
350 """
351 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
353 def Authenticate(self, request, context, session):
354 """
355 Authenticates a classic password based login request.
357 request.user can be any of id/username/email
358 """
359 logger.debug(f"Logging in with {request.user=}, password=*******")
360 user = session.execute(
361 select(User).where_username_or_email(request.user).where(~User.is_deleted)
362 ).scalar_one_or_none()
363 if user:
364 logger.debug("Found user")
365 if verify_password(user.hashed_password, request.password):
366 logger.debug("Right password")
367 # correct password
368 create_session(context, session, user, request.remember_device)
369 return _auth_res(user)
370 else:
371 logger.debug("Wrong password")
372 # wrong password
373 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_PASSWORD)
374 else: # user not found
375 # check if this is an email and they tried to sign up but didn't complete
376 signup_flow = session.execute(
377 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow)
378 ).scalar_one_or_none()
379 if signup_flow:
380 send_signup_email(session, signup_flow)
381 session.commit()
382 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
383 logger.debug("Didn't find user")
384 context.abort(grpc.StatusCode.NOT_FOUND, errors.ACCOUNT_NOT_FOUND)
386 def GetAuthState(self, request, context, session):
387 if not context.user_id:
388 return auth_pb2.GetAuthStateRes(logged_in=False)
389 else:
390 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
391 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
393 def Deauthenticate(self, request, context, session):
394 """
395 Removes an active cookie session.
396 """
397 token = parse_session_cookie(dict(context.invocation_metadata()))
398 logger.info(f"Deauthenticate(token={token})")
400 # if we had a token, try to remove the session
401 if token:
402 delete_session(session, token)
404 # set the cookie to an empty string and expire immediately, should remove it from the browser
405 context.send_initial_metadata([("set-cookie", cookie) for cookie in create_session_cookies("", "", now())])
407 return empty_pb2.Empty()
409 def ResetPassword(self, request, context, session):
410 """
411 If the user does not exist, do nothing.
413 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
414 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
416 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
417 """
418 user = session.execute(
419 select(User).where_username_or_email(request.user).where(~User.is_deleted)
420 ).scalar_one_or_none()
421 if user:
422 password_reset_token = PasswordResetToken(
423 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)
424 )
425 session.add(password_reset_token)
426 session.flush()
428 notify(
429 session,
430 user_id=user.id,
431 topic_action="password_reset:start",
432 data=notification_data_pb2.PasswordResetStart(
433 password_reset_token=password_reset_token.token,
434 ),
435 )
437 password_reset_initiations_counter.inc()
438 else: # user not found
439 logger.debug("Didn't find user")
441 return empty_pb2.Empty()
443 def CompletePasswordResetV2(self, request, context, session):
444 """
445 Completes the password reset: just clears the user's password
446 """
447 res = session.execute(
448 select(PasswordResetToken, User)
449 .join(User, User.id == PasswordResetToken.user_id)
450 .where(PasswordResetToken.token == request.password_reset_token)
451 .where(PasswordResetToken.is_valid)
452 ).one_or_none()
453 if res:
454 password_reset_token, user = res
455 abort_on_invalid_password(request.new_password, context)
456 user.hashed_password = hash_password(request.new_password)
457 session.delete(password_reset_token)
459 session.flush()
461 notify(
462 session,
463 user_id=user.id,
464 topic_action="password_reset:complete",
465 )
467 create_session(context, session, user, False)
468 password_reset_completions_counter.inc()
469 return _auth_res(user)
470 else:
471 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
473 def ConfirmChangeEmailV2(self, request, context, session):
474 user = session.execute(
475 select(User)
476 .where(User.new_email_token == request.change_email_token)
477 .where(User.new_email_token_created <= now())
478 .where(User.new_email_token_expiry >= now())
479 ).scalar_one_or_none()
481 if not user:
482 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
484 user.email = user.new_email
485 user.new_email = None
486 user.new_email_token = None
487 user.new_email_token_created = None
488 user.new_email_token_expiry = None
490 notify(
491 session,
492 user_id=user.id,
493 topic_action="email_address:verify",
494 )
496 return empty_pb2.Empty()
498 def ConfirmDeleteAccount(self, request, context, session):
499 """
500 Confirm account deletion using account delete token
501 """
502 res = session.execute(
503 select(User, AccountDeletionToken)
504 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
505 .where(AccountDeletionToken.token == request.token)
506 .where(AccountDeletionToken.is_valid)
507 ).one_or_none()
509 if not res:
510 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
512 user, account_deletion_token = res
514 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
516 user.is_deleted = True
517 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
518 user.undelete_token = urlsafe_secure_token()
520 session.flush()
522 notify(
523 session,
524 user_id=user.id,
525 topic_action="account_deletion:complete",
526 data=notification_data_pb2.AccountDeletionComplete(
527 undelete_token=user.undelete_token,
528 undelete_days=UNDELETE_DAYS,
529 ),
530 )
532 account_deletion_completions_counter.labels(user.gender).inc()
534 return empty_pb2.Empty()
536 def RecoverAccount(self, request, context, session):
537 """
538 Recovers a recently deleted account
539 """
540 user = session.execute(
541 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
542 ).scalar_one_or_none()
544 if not user:
545 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
547 user.is_deleted = False
548 user.undelete_token = None
549 user.undelete_until = None
551 notify(
552 session,
553 user_id=user.id,
554 topic_action="account_deletion:recovered",
555 )
557 account_recoveries_counter.labels(user.gender).inc()
559 return empty_pb2.Empty()
561 def Unsubscribe(self, request, context, session):
562 return auth_pb2.UnsubscribeRes(response=unsubscribe(request, context))