Coverage for src/couchers/servicers/auth.py: 95%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import logging
2from datetime import timedelta
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy.sql import delete, func
8from couchers import errors, urls
9from couchers.constants import GUIDELINES_VERSION, TOS_VERSION
10from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password
11from couchers.db import session_scope
12from couchers.models import (
13 AccountDeletionToken,
14 ContributorForm,
15 LoginToken,
16 PasswordResetToken,
17 SignupFlow,
18 User,
19 UserSession,
20)
21from couchers.notifications.notify import notify
22from couchers.notifications.unsubscribe import unsubscribe
23from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql
24from couchers.servicers.api import hostingstatus2sql
25from couchers.sql import couchers_select as select
26from couchers.tasks import (
27 enforce_community_memberships_for_user,
28 maybe_send_contributor_form_email,
29 send_account_deletion_successful_email,
30 send_account_recovered_email,
31 send_login_email,
32 send_onboarding_email,
33 send_password_reset_email,
34 send_signup_email,
35)
36from couchers.utils import (
37 create_coordinate,
38 create_session_cookie,
39 is_valid_email,
40 is_valid_name,
41 is_valid_username,
42 minimum_allowed_birthdate,
43 now,
44 parse_date,
45 parse_session_cookie,
46)
47from proto import auth_pb2, auth_pb2_grpc
49logger = logging.getLogger(__name__)
52def _auth_res(user):
53 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id)
56def create_session(context, session, user, long_lived, is_api_key=False, duration=None):
57 """
58 Creates a session for the given user and returns the token and expiry.
60 You need to give an active DB session as nested sessions don't really
61 work here due to the active User object.
63 Will abort the API calling context if the user is banned from logging in.
65 You can set the cookie on the client (if `is_api_key=False`) with
67 ```py3
68 token, expiry = create_session(...)
69 context.send_initial_metadata([("set-cookie", create_session_cookie(token, expiry)),])
70 ```
71 """
72 if user.is_banned:
73 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.ACCOUNT_SUSPENDED)
75 # just double check
76 assert not user.is_deleted
78 token = cookiesafe_secure_token()
80 headers = dict(context.invocation_metadata())
82 user_session = UserSession(
83 token=token,
84 user=user,
85 long_lived=long_lived,
86 ip_address=headers.get("x-forwarded-for"),
87 user_agent=headers.get("user-agent"),
88 is_api_key=is_api_key,
89 )
90 if duration:
91 user_session.expiry = func.now() + duration
93 session.add(user_session)
94 session.commit()
96 logger.debug(f"Handing out {token=} to {user=}")
97 return token, user_session.expiry
100def delete_session(token):
101 """
102 Deletes the given session (practically logging the user out)
104 Returns True if the session was found, False otherwise.
105 """
106 with session_scope() as session:
107 user_session = session.execute(
108 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid)
109 ).scalar_one_or_none()
110 if user_session:
111 user_session.deleted = func.now()
112 session.commit()
113 return True
114 else:
115 return False
118class Auth(auth_pb2_grpc.AuthServicer):
119 """
120 The Auth servicer.
122 This class services the Auth service/API.
123 """
125 def _username_available(self, username):
126 """
127 Checks if the given username adheres to our rules and isn't taken already.
128 """
129 logger.debug(f"Checking if {username=} is valid")
130 if not is_valid_username(username):
131 return False
132 with session_scope() as session:
133 # check for existing user with that username
134 user_exists = (
135 session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None
136 )
137 # check for started signup with that username
138 signup_exists = (
139 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none()
140 is not None
141 )
142 # return False if user exists, True otherwise
143 return not user_exists and not signup_exists
145 def SignupFlow(self, request, context):
146 with session_scope() as session:
147 if request.email_token:
148 # the email token can either be for verification or just to find an existing signup
149 flow = session.execute(
150 select(SignupFlow)
151 .where(SignupFlow.email_verified == False)
152 .where(SignupFlow.email_token == request.email_token)
153 .where(SignupFlow.token_is_valid)
154 ).scalar_one_or_none()
155 if flow:
156 # find flow by email verification token and mark it as verified
157 flow.email_verified = True
158 flow.email_token = None
159 flow.email_token_expiry = None
161 session.flush()
162 else:
163 # just try to find the flow by flow token, no verification is done
164 flow = session.execute(
165 select(SignupFlow).where(SignupFlow.flow_token == request.email_token)
166 ).scalar_one_or_none()
167 if not flow:
168 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
169 else:
170 if not request.flow_token:
171 # fresh signup
172 if not request.HasField("basic"):
173 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.SIGNUP_FLOW_BASIC_NEEDED)
174 # TODO: unique across both tables
175 existing_user = session.execute(
176 select(User).where(User.email == request.basic.email)
177 ).scalar_one_or_none()
178 if existing_user:
179 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_TAKEN)
180 existing_flow = session.execute(
181 select(SignupFlow).where(SignupFlow.email == request.basic.email)
182 ).scalar_one_or_none()
183 if existing_flow:
184 send_signup_email(existing_flow)
185 session.commit()
186 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP)
188 if not is_valid_email(request.basic.email):
189 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
190 if not is_valid_name(request.basic.name):
191 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME)
193 flow_token = cookiesafe_secure_token()
195 flow = SignupFlow(
196 flow_token=flow_token,
197 name=request.basic.name,
198 email=request.basic.email,
199 )
200 session.add(flow)
201 session.flush()
202 else:
203 # not fresh signup
204 flow = session.execute(
205 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token)
206 ).scalar_one_or_none()
207 if not flow:
208 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
209 if request.HasField("basic"):
210 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_BASIC_FILLED)
212 # we've found and/or created a new flow, now sort out other parts
213 if request.HasField("account"):
214 if flow.account_is_filled:
215 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_ACCOUNT_FILLED)
217 # check username validity
218 if not is_valid_username(request.account.username):
219 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_USERNAME)
221 if not self._username_available(request.account.username):
222 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.USERNAME_NOT_AVAILABLE)
224 abort_on_invalid_password(request.account.password, context)
225 hashed_password = hash_password(request.account.password)
227 birthdate = parse_date(request.account.birthdate)
228 if not birthdate or birthdate >= minimum_allowed_birthdate():
229 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.INVALID_BIRTHDATE)
231 if not request.account.hosting_status:
232 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.HOSTING_STATUS_REQUIRED)
234 if request.account.lat == 0 and request.account.lng == 0:
235 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE)
237 if not request.account.accept_tos:
238 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_TOS)
240 flow.username = request.account.username
241 flow.hashed_password = hashed_password
242 flow.birthdate = birthdate
243 flow.gender = request.account.gender
244 flow.hosting_status = hostingstatus2sql[request.account.hosting_status]
245 flow.city = request.account.city
246 flow.geom = create_coordinate(request.account.lat, request.account.lng)
247 flow.geom_radius = request.account.radius
248 flow.accepted_tos = TOS_VERSION
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:
273 send_signup_email(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 )
296 session.add(user)
298 form = ContributorForm(
299 user=user,
300 ideas=flow.ideas or None,
301 features=flow.features or None,
302 experience=flow.experience or None,
303 contribute=flow.contribute or None,
304 contribute_ways=flow.contribute_ways,
305 expertise=flow.expertise or None,
306 )
308 session.add(form)
310 user.filled_contributor_form = form.is_filled
312 session.delete(flow)
313 session.commit()
315 enforce_community_memberships_for_user(session, user)
317 if form.is_filled:
318 user.filled_contributor_form = True
320 maybe_send_contributor_form_email(form)
322 send_onboarding_email(user, email_number=1)
324 token, expiry = create_session(context, session, user, False)
325 context.send_initial_metadata(
326 [
327 ("set-cookie", create_session_cookie(token, expiry)),
328 ]
329 )
330 return auth_pb2.SignupFlowRes(
331 auth_res=_auth_res(user),
332 )
333 else:
334 return auth_pb2.SignupFlowRes(
335 flow_token=flow.flow_token,
336 need_account=not flow.account_is_filled,
337 need_feedback=not flow.filled_feedback,
338 need_verify_email=not flow.email_verified,
339 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION,
340 )
342 def UsernameValid(self, request, context):
343 """
344 Runs a username availability and validity check.
345 """
346 return auth_pb2.UsernameValidRes(valid=self._username_available(request.username.lower()))
348 def Login(self, request, context):
349 """
350 Does the first step of the Login flow.
352 The user is searched for using their id, username, or email.
354 If the user does not exist or has been deleted, throws a NOT_FOUND rpc error.
356 If the user has a password, returns NEED_PASSWORD.
358 If the user exists but does not have a password, generates a login token, send it in the email and returns SENT_LOGIN_EMAIL.
359 """
360 logger.debug(f"Attempting login for {request.user=}")
361 with session_scope() as session:
362 # if the user is banned, they can get past this but get an error later in login flow
363 user = session.execute(
364 select(User).where_username_or_email(request.user).where(~User.is_deleted)
365 ).scalar_one_or_none()
366 if user:
367 if user.has_password:
368 logger.debug(f"Found user with password")
369 return auth_pb2.LoginRes(next_step=auth_pb2.LoginRes.LoginStep.NEED_PASSWORD)
370 else:
371 logger.debug(f"Found user without password, sending login email")
372 send_login_email(session, user)
373 return auth_pb2.LoginRes(next_step=auth_pb2.LoginRes.LoginStep.SENT_LOGIN_EMAIL)
374 else: # user not found
375 logger.debug(f"Didn't find user")
376 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
378 def CompleteTokenLogin(self, request, context):
379 """
380 Second step of email-based login.
382 Validates the given LoginToken (sent in email), creates a new session and returns bearer token.
384 Or fails with grpc.NOT_FOUND if LoginToken is invalid.
385 """
386 with session_scope() as session:
387 res = session.execute(
388 select(LoginToken, User)
389 .join(User, User.id == LoginToken.user_id)
390 .where(LoginToken.token == request.login_token)
391 .where(LoginToken.is_valid)
392 ).one_or_none()
393 if res:
394 login_token, user = res
396 # delete the login token so it can't be reused
397 session.delete(login_token)
398 session.commit()
400 # create a session
401 token, expiry = create_session(context, session, user, False)
402 context.send_initial_metadata(
403 [
404 ("set-cookie", create_session_cookie(token, expiry)),
405 ]
406 )
407 return _auth_res(user)
408 else:
409 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
411 def Authenticate(self, request, context):
412 """
413 Authenticates a classic password based login request.
415 request.user can be any of id/username/email
416 """
417 logger.debug(f"Logging in with {request.user=}, password=*******")
418 with session_scope() as session:
419 user = session.execute(
420 select(User).where_username_or_email(request.user).where(~User.is_deleted)
421 ).scalar_one_or_none()
422 if user:
423 logger.debug(f"Found user")
424 if not user.has_password:
425 logger.debug(f"User doesn't have a password!")
426 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PASSWORD)
427 if verify_password(user.hashed_password, request.password):
428 logger.debug(f"Right password")
429 # correct password
430 token, expiry = create_session(context, session, user, request.remember_device)
431 context.send_initial_metadata(
432 [
433 ("set-cookie", create_session_cookie(token, expiry)),
434 ]
435 )
436 return _auth_res(user)
437 else:
438 logger.debug(f"Wrong password")
439 # wrong password
440 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD)
441 else: # user not found
442 logger.debug(f"Didn't find user")
443 # do about as much work as if the user was found, reduces timing based username enumeration attacks
444 hash_password(request.password)
445 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD)
447 def Deauthenticate(self, request, context):
448 """
449 Removes an active cookie session.
450 """
451 token = parse_session_cookie(dict(context.invocation_metadata()))
452 logger.info(f"Deauthenticate(token={token})")
454 # if we had a token, try to remove the session
455 if token:
456 delete_session(token)
458 # set the cookie to an empty string and expire immediately, should remove it from the browser
459 context.send_initial_metadata(
460 [
461 ("set-cookie", create_session_cookie("", now())),
462 ]
463 )
465 return empty_pb2.Empty()
467 def ResetPassword(self, request, context):
468 """
469 If the user does not exist, do nothing.
471 If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
472 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.
474 Note that as long as emails are send synchronously, this is far from constant time regardless of output.
475 """
476 with session_scope() as session:
477 user = session.execute(
478 select(User).where_username_or_email(request.user).where(~User.is_deleted)
479 ).scalar_one_or_none()
480 if user:
481 send_password_reset_email(session, user)
483 notify(
484 user_id=user.id,
485 topic="account_recovery",
486 key="",
487 action="start",
488 icon="wrench",
489 title=f"Password reset initiated",
490 link=urls.account_settings_link(),
491 )
493 else: # user not found
494 logger.debug(f"Didn't find user")
496 return empty_pb2.Empty()
498 def CompletePasswordReset(self, request, context):
499 """
500 Completes the password reset: just clears the user's password
501 """
502 with session_scope() as session:
503 res = session.execute(
504 select(PasswordResetToken, User)
505 .join(User, User.id == PasswordResetToken.user_id)
506 .where(PasswordResetToken.token == request.password_reset_token)
507 .where(PasswordResetToken.is_valid)
508 ).one_or_none()
509 if res:
510 password_reset_token, user = res
511 session.delete(password_reset_token)
512 user.hashed_password = None
513 session.commit()
515 notify(
516 user_id=user.id,
517 topic="account_recovery",
518 key="",
519 action="complete",
520 icon="wrench",
521 title=f"Password reset completed",
522 link=urls.account_settings_link(),
523 )
525 return empty_pb2.Empty()
526 else:
527 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
529 def ConfirmChangeEmail(self, request, context):
530 with session_scope() as session:
531 user_with_valid_token_from_old_email = session.execute(
532 select(User)
533 .where(User.old_email_token == request.change_email_token)
534 .where(User.old_email_token_created <= now())
535 .where(User.old_email_token_expiry >= now())
536 ).scalar_one_or_none()
537 user_with_valid_token_from_new_email = session.execute(
538 select(User)
539 .where(User.new_email_token == request.change_email_token)
540 .where(User.new_email_token_created <= now())
541 .where(User.new_email_token_expiry >= now())
542 ).scalar_one_or_none()
544 if user_with_valid_token_from_old_email:
545 user = user_with_valid_token_from_old_email
546 user.old_email_token = None
547 user.old_email_token_created = None
548 user.old_email_token_expiry = None
549 user.need_to_confirm_via_old_email = False
550 elif user_with_valid_token_from_new_email:
551 user = user_with_valid_token_from_new_email
552 user.new_email_token = None
553 user.new_email_token_created = None
554 user.new_email_token_expiry = None
555 user.need_to_confirm_via_new_email = False
556 else:
557 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
559 # Using "___ is False" instead of "not ___" so that "None" doesn't pass
560 if user.need_to_confirm_via_old_email is False and user.need_to_confirm_via_new_email is False:
561 user.email = user.new_email
562 user.new_email = None
563 user.need_to_confirm_via_old_email = None
564 user.need_to_confirm_via_new_email = None
566 notify(
567 user_id=user.id,
568 topic="email_address",
569 key="",
570 action="change",
571 icon="wrench",
572 title=f"Your email was changed",
573 link=urls.account_settings_link(),
574 )
576 return auth_pb2.ConfirmChangeEmailRes(state=auth_pb2.EMAIL_CONFIRMATION_STATE_SUCCESS)
577 elif user.need_to_confirm_via_old_email:
578 return auth_pb2.ConfirmChangeEmailRes(
579 state=auth_pb2.EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_OLD_EMAIL
580 )
581 else:
582 return auth_pb2.ConfirmChangeEmailRes(
583 state=auth_pb2.EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_NEW_EMAIL
584 )
586 def ConfirmDeleteAccount(self, request, context):
587 """
588 Confirm account deletion using account delete token
589 """
590 with session_scope() as session:
591 res = session.execute(
592 select(User, AccountDeletionToken)
593 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id)
594 .where(AccountDeletionToken.token == request.token)
595 .where(AccountDeletionToken.is_valid)
596 ).one_or_none()
598 if not res:
599 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
601 user, account_deletion_token = res
603 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id))
605 undelete_days = 7
606 user.is_deleted = True
607 user.undelete_until = now() + timedelta(days=undelete_days)
608 user.undelete_token = urlsafe_secure_token()
610 send_account_deletion_successful_email(user, undelete_days)
612 return empty_pb2.Empty()
614 def RecoverAccount(self, request, context):
615 """
616 Recovers a recently deleted account
617 """
618 with session_scope() as session:
619 user = session.execute(
620 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now())
621 ).scalar_one_or_none()
623 if not user:
624 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
626 user.is_deleted = False
627 user.undelete_token = None
628 user.undelete_until = None
629 send_account_recovered_email(user)
631 return empty_pb2.Empty()
633 def Unsubscribe(self, request, context):
634 return auth_pb2.UnsubscribeRes(response=unsubscribe(request, context))