Coverage for src/couchers/servicers/auth.py: 95%
247 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-12-20 18:03 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-12-20 18:03 +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 form = ContributorForm(
300 user=user,
301 ideas=flow.ideas or None,
302 features=flow.features or None,
303 experience=flow.experience or None,
304 contribute=flow.contribute or None,
305 contribute_ways=flow.contribute_ways,
306 expertise=flow.expertise or None,
307 )
309 session.add(form)
311 user.filled_contributor_form = form.is_filled
313 signup_duration_s = (now() - flow.created).total_seconds()
315 session.delete(flow)
316 session.commit()
318 enforce_community_memberships_for_user(session, user)
320 if form.is_filled:
321 user.filled_contributor_form = True
323 maybe_send_contributor_form_email(session, form)
325 # sends onboarding email
326 notify(
327 session,
328 user_id=user.id,
329 topic_action="onboarding:reminder",
330 key="1",
331 )
333 signup_completions_counter.labels(flow.gender).inc()
334 signup_time_histogram.labels(flow.gender).observe(signup_duration_s)
336 create_session(context, session, user, False)
337 return auth_pb2.SignupFlowRes(
338 auth_res=_auth_res(user),
339 )
340 else:
341 return auth_pb2.SignupFlowRes(
342 flow_token=flow.flow_token,
343 need_account=not flow.account_is_filled,
344 need_feedback=not flow.filled_feedback,
345 need_verify_email=not flow.email_verified,
346 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
347 )
349 def UsernameValid(self, request, context, session):
350 """
351 Runs a username availability and validity check.
352 """
353 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower()))
355 def Authenticate(self, request, context, session):
356 """
357 Authenticates a classic password based login request.
359 request.user can be any of id/username/email
360 """
361 logger.debug(f"Logging in with {request.user=}, password=*******")
362 user = session.execute(
363 select(User).where_username_or_email(request.user).where(~User.is_deleted)
364 ).scalar_one_or_none()
365 if user:
366 logger.debug("Found user")
367 if verify_password(user.hashed_password, request.password):
368 logger.debug("Right password")
369 # correct password
370 create_session(context, session, user, request.remember_device)
371 return _auth_res(user)
372 else:
373 logger.debug("Wrong password")
374 # wrong password
375 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_PASSWORD)
376 else: # user not found
377 # check if this is an email and they tried to sign up but didn't complete
378 signup_flow = session.execute(
379 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow)
380 ).scalar_one_or_none()
381 if signup_flow:
382 send_signup_email(session, signup_flow)
383 session.commit()
384 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
385 logger.debug("Didn't find user")
386 context.abort(grpc.StatusCode.NOT_FOUND, errors.ACCOUNT_NOT_FOUND)
388 def GetAuthState(self, request, context, session):
389 if not context.user_id:
390 return auth_pb2.GetAuthStateRes(logged_in=False)
391 else:
392 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
393 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user))
395 def Deauthenticate(self, request, context, session):
396 """
397 Removes an active cookie session.
398 """
399 token = parse_session_cookie(dict(context.invocation_metadata()))
400 logger.info(f"Deauthenticate(token={token})")
402 # if we had a token, try to remove the session
403 if token:
404 delete_session(session, token)
406 # set the cookie to an empty string and expire immediately, should remove it from the browser
407 context.send_initial_metadata([("set-cookie", cookie) for cookie in create_session_cookies("", "", now())])
409 return empty_pb2.Empty()
411 def ResetPassword(self, request, context, session):
412 """
413 If the user does not exist, do nothing.
415 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
416 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
418 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
419 """
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 password_reset_token = PasswordResetToken(
425 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)
426 )
427 session.add(password_reset_token)
428 session.flush()
430 notify(
431 session,
432 user_id=user.id,
433 topic_action="password_reset:start",
434 data=notification_data_pb2.PasswordResetStart(
435 password_reset_token=password_reset_token.token,
436 ),
437 )
439 password_reset_initiations_counter.inc()
440 else: # user not found
441 logger.debug("Didn't find user")
443 return empty_pb2.Empty()
445 def CompletePasswordResetV2(self, request, context, session):
446 """
447 Completes the password reset: just clears the user's password
448 """
449 res = session.execute(
450 select(PasswordResetToken, User)
451 .join(User, User.id == PasswordResetToken.user_id)
452 .where(PasswordResetToken.token == request.password_reset_token)
453 .where(PasswordResetToken.is_valid)
454 ).one_or_none()
455 if res:
456 password_reset_token, user = res
457 abort_on_invalid_password(request.new_password, context)
458 user.hashed_password = hash_password(request.new_password)
459 session.delete(password_reset_token)
461 session.flush()
463 notify(
464 session,
465 user_id=user.id,
466 topic_action="password_reset:complete",
467 )
469 create_session(context, session, user, False)
470 password_reset_completions_counter.inc()
471 return _auth_res(user)
472 else:
473 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
475 def ConfirmChangeEmailV2(self, request, context, session):
476 user = session.execute(
477 select(User)
478 .where(User.new_email_token == request.change_email_token)
479 .where(User.new_email_token_created <= now())
480 .where(User.new_email_token_expiry >= now())
481 ).scalar_one_or_none()
483 if not user:
484 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
486 user.email = user.new_email
487 user.new_email = None
488 user.new_email_token = None
489 user.new_email_token_created = None
490 user.new_email_token_expiry = None
492 notify(
493 session,
494 user_id=user.id,
495 topic_action="email_address:verify",
496 )
498 return empty_pb2.Empty()
500 def ConfirmDeleteAccount(self, request, context, session):
501 """
502 Confirm account deletion using account delete token
503 """
504 res = session.execute(
505 select(User, AccountDeletionToken)
506 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
507 .where(AccountDeletionToken.token == request.token)
508 .where(AccountDeletionToken.is_valid)
509 ).one_or_none()
511 if not res:
512 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
514 user, account_deletion_token = res
516 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
518 user.is_deleted = True
519 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS)
520 user.undelete_token = urlsafe_secure_token()
522 session.flush()
524 notify(
525 session,
526 user_id=user.id,
527 topic_action="account_deletion:complete",
528 data=notification_data_pb2.AccountDeletionComplete(
529 undelete_token=user.undelete_token,
530 undelete_days=UNDELETE_DAYS,
531 ),
532 )
534 account_deletion_completions_counter.labels(user.gender).inc()
536 return empty_pb2.Empty()
538 def RecoverAccount(self, request, context, session):
539 """
540 Recovers a recently deleted account
541 """
542 user = session.execute(
543 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
544 ).scalar_one_or_none()
546 if not user:
547 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
549 user.is_deleted = False
550 user.undelete_token = None
551 user.undelete_until = None
553 notify(
554 session,
555 user_id=user.id,
556 topic_action="account_deletion:recovered",
557 )
559 account_recoveries_counter.labels(user.gender).inc()
561 return empty_pb2.Empty()
563 def Unsubscribe(self, request, context, session):
564 return auth_pb2.UnsubscribeRes(response=unsubscribe(request, context))