def Authenticate(self, request, context): """ Authenticates a classic password based login request. request.user can be any of id/username/email """ logger.debug(f"Logging in with {request.user=}, password=*******") with session_scope() as session: user = get_user_by_field(session, request.user) if user: logger.debug(f"Found user") if not user.hashed_password: logger.debug(f"User doesn't have a password!") context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PASSWORD) if verify_password(user.hashed_password, request.password): logger.debug(f"Right password") # correct password token, expiry = self._create_session(context, session, user, request.remember_device) context.send_initial_metadata( [ ("set-cookie", create_session_cookie(token, expiry)), ] ) return auth_pb2.AuthRes(jailed=user.is_jailed) else: logger.debug(f"Wrong password") # wrong password context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD) else: # user not found logger.debug(f"Didn't find user") # do about as much work as if the user was found, reduces timing based username enumeration attacks hash_password(request.password) context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD)
def Authenticate(self, request, context): """ Authenticates a classic password based login request. request.user can be any of id/username/email """ logging.debug(f"Logging in with {request.user=}, password=*******") with session_scope(self._Session) as session: user = get_user_by_field(session, request.user) if user: logging.debug(f"Found user") if not user.hashed_password: logging.debug(f"User doesn't have a password!") context.abort(grpc.StatusCode.FAILED_PRECONDITION, "User does not have a password") if verify_password(user.hashed_password, request.password): logging.debug(f"Right password") # correct password token = self._create_session(session, user) return auth_pb2.AuthRes(token=token) else: logging.debug(f"Wrong password") # wrong password context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password") else: # user not found logging.debug(f"Didn't find user") # do about as much work as if the user was found, reduces timing based username enumeration attacks hash_password(request.password) context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
def test_ChangePassword_normal_no_passwords(db, fast_passwords): # user has old password and called with empty body old_password = random_hex() user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangePassword(account_pb2.ChangePasswordReq()) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.MISSING_BOTH_PASSWORDS with session_scope() as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(old_password)
def ChangePassword(self, request, context): """ Changes the user's password. They have to confirm their old password just in case. If they didn't have an old password previously, then we don't check that. """ with session_scope() as session: user = session.query(User).filter(User.id == context.user_id).one() if not request.HasField("old_password") and not request.HasField( "new_password"): context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MISSING_BOTH_PASSWORDS) _check_password(user, "old_password", request, context) # password correct or no password if not request.HasField("new_password"): # the user wants to unset their password user.hashed_password = None else: _abort_if_terrible_password(request.new_password.value, context) user.hashed_password = hash_password( request.new_password.value) session.commit() send_password_changed_email(user) return empty_pb2.Empty()
def test_ChangeEmail_wrong_token(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(db, hashed_password=hash_password(password)) with account_session(db, token) as account: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=new_email, )) with session_scope(db) as session: user_updated = (session.query(User).filter(User.id == user.id).filter( User.new_email == new_email).filter( User.new_email_token_created <= func.now()).filter( User.new_email_token_expiry >= func.now())).one() token = user_updated.new_email_token with auth_api_session(db) as auth_api: with pytest.raises(grpc.RpcError) as e: res = auth_api.CompleteChangeEmail( auth_pb2.CompleteChangeEmailReq( change_email_token="wrongtoken", )) assert e.value.code() == grpc.StatusCode.UNAUTHENTICATED assert e.value.details() == errors.INVALID_TOKEN with session_scope(db) as session: user_updated2 = session.query(User).filter(User.id == user.id).one() assert user_updated2.email == user.email
def test_ChangeEmail_sends_proper_emails_has_password(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(hashed_password=hash_password(password)) with account_session(token) as account: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=new_email, )) with session_scope() as session: jobs = (session.execute( select(BackgroundJob).where( BackgroundJob.job_type == BackgroundJobType.send_email)).scalars().all()) assert len(jobs) == 2 payload_for_notification_email = jobs[0].payload payload_for_confirmation_email_new_address = jobs[1].payload unique_string_notification_email_as_bytes = b"You requested that your email on Couchers.org be changed to" unique_string_for_confirmation_email_new_email_address_as_bytes = ( b"You requested that your email be changed to this email address on Couchers.org" ) assert unique_string_notification_email_as_bytes in payload_for_notification_email assert (unique_string_for_confirmation_email_new_email_address_as_bytes in payload_for_confirmation_email_new_address)
def test_ChangeEmail(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(db, hashed_password=hash_password(password)) with account_session(db, token) as account: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=new_email, )) with session_scope(db) as session: user_updated = (session.query(User).filter(User.id == user.id).filter( User.new_email == new_email).filter( User.new_email_token_created <= func.now()).filter( User.new_email_token_expiry >= func.now())).one() token = user_updated.new_email_token with auth_api_session(db) as auth_api: res = auth_api.CompleteChangeEmail( auth_pb2.CompleteChangeEmailReq(change_email_token=token, )) with session_scope(db) as session: user_updated2 = session.query(User).filter(User.id == user.id).one() assert user_updated2.email == new_email assert user_updated2.new_email is None assert user_updated2.new_email_token is None # check there's no valid tokens left with session_scope(db) as session: assert (session.query(User).filter( User.new_email_token_created <= func.now()).filter( User.new_email_token_expiry >= func.now())).count() == 0
def add_dummy_data(file_name): with session_scope(Session) as session: with open(file_name, "r") as file: users = json.loads(file.read()) for user in users: new_user = User( username=user["username"], email=user["email"], hashed_password=hash_password(user["password"]) if user["password"] else None, name=user["name"], city=user["city"], verification=user["verification"], community_standing=user["community_standing"], birthdate=date(year=user["birthdate"]["year"], month=user["birthdate"]["month"], day=user["birthdate"]["day"]), gender=user["gender"], languages="|".join(user["languages"]), occupation=user["occupation"], about_me=user["about_me"], about_place=user["about_place"], countries_visited="|".join(user["countries_visited"]), countries_lived="|".join(user["countries_lived"]), ) session.add(new_user)
def test_ChangePassword_remove_wrong_password(db, fast_passwords): old_password = random_hex() user, token = generate_user(db, hashed_password=hash_password(old_password)) with account_session(db, token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue( value="wrong password"), )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD with session_scope(db) as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(old_password)
def test_ChangePassword_regression(db, fast_passwords): # send_password_changed_email wasn't working # user has old password and is changing to new password old_password = random_hex() new_password = random_hex() user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue(value=old_password), new_password=wrappers_pb2.StringValue(value=new_password), )) with session_scope() as session: updated_user = session.execute( select(User).where(User.id == user.id)).scalar_one() assert updated_user.hashed_password == hash_password(new_password)
def test_ChangePassword_normal(db, fast_passwords): # user has old password and is changing to new password old_password = random_hex() new_password = random_hex() user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: with patch("couchers.servicers.account.send_password_changed_email") as mock: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue(value=old_password), new_password=wrappers_pb2.StringValue(value=new_password), ) ) mock.assert_called_once() with session_scope() as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(new_password)
def test_password_reset_invalid_token(db): password = random_hex() user, token = generate_user(hashed_password=hash_password(password)) with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.ResetPassword( auth_pb2.ResetPasswordReq(user=user.username, )) with auth_api_session() as (auth_api, metadata_interceptor), pytest.raises( grpc.RpcError) as e: res = auth_api.CompletePasswordReset( auth_pb2.CompletePasswordResetReq( password_reset_token="wrongtoken")) assert e.value.code() == grpc.StatusCode.NOT_FOUND assert e.value.details() == errors.INVALID_TOKEN with session_scope() as session: user = session.execute(select(User)).scalar_one() assert user.hashed_password == hash_password(password)
def test_ChangePassword_normal_no_password(db, fast_passwords): # user has old password and is changing to new password, but didn't supply old password old_password = random_hex() new_password = random_hex() user, token = generate_user(db, hashed_password=hash_password(old_password)) with account_session(db, token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangePassword( account_pb2.ChangePasswordReq( new_password=wrappers_pb2.StringValue( value=new_password), )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.MISSING_PASSWORD with session_scope(db) as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(old_password)
def test_ChangePassword_normal_long_password(db, fast_passwords): # user has old password and is changing to new password, but used short password old_password = random_hex() new_password = random_hex(length=1000) user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue(value=old_password), new_password=wrappers_pb2.StringValue(value=new_password), ) ) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.PASSWORD_TOO_LONG with session_scope() as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(old_password)
def test_ChangePassword_normal_insecure_password(db, fast_passwords): # user has old password and is changing to new password, but used insecure password old_password = random_hex() new_password = "******" user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue(value=old_password), new_password=wrappers_pb2.StringValue(value=new_password), )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.INSECURE_PASSWORD with session_scope() as session: updated_user = session.execute( select(User).where(User.id == user.id)).scalar_one() assert updated_user.hashed_password == hash_password(old_password)
def test_ChangeEmail_email_in_use(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(db, hashed_password=hash_password(password)) user2, token2 = generate_user(db, hashed_password=hash_password(password)) with account_session(db, token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=user2.email, )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.INVALID_EMAIL with session_scope(db) as session: assert (session.query(User).filter( User.new_email_token_created <= func.now()).filter( User.new_email_token_expiry >= func.now())).count() == 0
def test_password_reset_invalid_token(db, fast_passwords): password = random_hex() user, token = generate_user(db, hashed_password=hash_password(password)) with auth_api_session(db) as auth_api: res = auth_api.ResetPassword( auth_pb2.ResetPasswordReq(user=user.username, )) with session_scope(db) as session: token = session.query(PasswordResetToken).one_or_none().token with auth_api_session(db) as auth_api, pytest.raises(grpc.RpcError) as e: res = auth_api.CompletePasswordReset( auth_pb2.CompletePasswordResetReq( password_reset_token="wrongtoken")) assert e.value.code() == grpc.StatusCode.UNAUTHENTICATED assert e.value.details() == errors.INVALID_TOKEN with session_scope(db) as session: user = session.query(User).one() assert user.hashed_password == hash_password(password)
def test_ChangeEmail_email_in_use(db, fast_passwords): password = random_hex() user, token = generate_user(hashed_password=hash_password(password)) user2, token2 = generate_user(hashed_password=hash_password(password)) with account_session(token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=user2.email, )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.INVALID_EMAIL with session_scope() as session: assert (session.execute( select(func.count()).select_from(User).where( User.new_email_token_created <= func.now()).where( User.new_email_token_expiry >= func.now())) ).scalar_one() == 0
def test_successful_authenticate(db): user, _ = generate_user(hashed_password=hash_password("password")) # Authenticate with username with auth_api_session() as (auth_api, metadata_interceptor): reply = auth_api.Authenticate( auth_pb2.AuthReq(user=user.username, password="******")) assert not reply.jailed # Authenticate with email with auth_api_session() as (auth_api, metadata_interceptor): reply = auth_api.Authenticate( auth_pb2.AuthReq(user=user.email, password="******")) assert not reply.jailed
def test_GetAccountInfo(db, fast_passwords): # without password user1, token1 = generate_user(hashed_password=None) with account_session(token1) as account: res = account.GetAccountInfo(empty_pb2.Empty()) assert res.login_method == account_pb2.GetAccountInfoRes.LoginMethod.MAGIC_LINK assert not res.has_password # with password user1, token1 = generate_user(hashed_password=hash_password(random_hex())) with account_session(token1) as account: res = account.GetAccountInfo(empty_pb2.Empty()) assert res.login_method == account_pb2.GetAccountInfoRes.LoginMethod.PASSWORD assert res.has_password
def test_unsuccessful_authenticate(db): user, _ = generate_user(hashed_password=hash_password("password")) # Invalid password with auth_api_session() as (auth_api, metadata_interceptor): with pytest.raises(grpc.RpcError) as e: reply = auth_api.Authenticate( auth_pb2.AuthReq(user=user.username, password="******")) assert e.value.code() == grpc.StatusCode.NOT_FOUND assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD # Invalid username with auth_api_session() as (auth_api, metadata_interceptor): with pytest.raises(grpc.RpcError) as e: reply = auth_api.Authenticate( auth_pb2.AuthReq(user="******", password="******")) assert e.value.code() == grpc.StatusCode.NOT_FOUND assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD # Invalid email with auth_api_session() as (auth_api, metadata_interceptor): with pytest.raises(grpc.RpcError) as e: reply = auth_api.Authenticate( auth_pb2.AuthReq(user=f"{random_hex(12)}@couchers.org.invalid", password="******")) assert e.value.code() == grpc.StatusCode.NOT_FOUND assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD # Invalid id with auth_api_session() as (auth_api, metadata_interceptor): with pytest.raises(grpc.RpcError) as e: reply = auth_api.Authenticate( auth_pb2.AuthReq(user="******", password="******")) assert e.value.code() == grpc.StatusCode.NOT_FOUND assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD # No Password user_without_pass, _ = generate_user(hashed_password=None) with auth_api_session() as (auth_api, metadata_interceptor): with pytest.raises(grpc.RpcError) as e: reply = auth_api.Authenticate( auth_pb2.AuthReq(user=user_without_pass.username, password="******")) assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION assert e.value.details() == errors.NO_PASSWORD
def test_password_reset(db): user, token = generate_user(hashed_password=hash_password("mypassword")) with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.ResetPassword( auth_pb2.ResetPasswordReq(user=user.username, )) with session_scope() as session: token = session.execute(select(PasswordResetToken)).scalar_one().token with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.CompletePasswordReset( auth_pb2.CompletePasswordResetReq(password_reset_token=token)) with session_scope() as session: user = session.execute(select(User)).scalar_one() assert not user.has_password
def test_password_reset(db, fast_passwords): user, token = generate_user(hashed_password=hash_password("mypassword")) with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.ResetPassword( auth_pb2.ResetPasswordReq(user=user.username, )) with session_scope() as session: token = session.query(PasswordResetToken).one().token with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.CompletePasswordReset( auth_pb2.CompletePasswordResetReq(password_reset_token=token)) with session_scope() as session: user = session.query(User).one() assert user.hashed_password is None
def test_ChangeEmail_has_password(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(hashed_password=hash_password(password)) with account_session(token) as account: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value=password), new_email=new_email, )) with session_scope() as session: user_updated = session.execute( select(User).where(User.id == user.id)).scalar_one() assert user_updated.email == user.email assert user_updated.new_email == new_email assert user_updated.old_email_token is None assert not user_updated.old_email_token_created assert not user_updated.old_email_token_expiry assert not user_updated.need_to_confirm_via_old_email assert user_updated.new_email_token is not None assert user_updated.new_email_token_created <= now() assert user_updated.new_email_token_expiry >= now() assert user_updated.need_to_confirm_via_new_email token = user_updated.new_email_token with auth_api_session() as (auth_api, metadata_interceptor): res = auth_api.ConfirmChangeEmail( auth_pb2.ConfirmChangeEmailReq(change_email_token=token, )) assert res.state == auth_pb2.EMAIL_CONFIRMATION_STATE_SUCCESS with session_scope() as session: user = session.execute( select(User).where(User.id == user.id)).scalar_one() assert user.email == new_email assert user.new_email is None assert user.old_email_token is None assert user.old_email_token_created is None assert user.old_email_token_expiry is None assert not user.need_to_confirm_via_old_email assert user.new_email_token is None assert user.new_email_token_created is None assert user.new_email_token_expiry is None assert not user.need_to_confirm_via_new_email
def test_ChangePassword_add(db, fast_passwords): # user does not have an old password and is adding a new password new_password = random_hex() user, token = generate_user(db, hashed_password=None) with account_session(db, token) as account: with patch("couchers.servicers.account.send_password_changed_email" ) as mock: account.ChangePassword( account_pb2.ChangePasswordReq( new_password=wrappers_pb2.StringValue( value=new_password), )) mock.assert_called_once() with session_scope(db) as session: updated_user = session.query(User).filter(User.id == user.id).one() assert updated_user.hashed_password == hash_password(new_password)
def test_ChangePassword_remove(db, fast_passwords): old_password = random_hex() user, token = generate_user(hashed_password=hash_password(old_password)) with account_session(token) as account: with patch("couchers.servicers.account.send_password_changed_email" ) as mock: account.ChangePassword( account_pb2.ChangePasswordReq( old_password=wrappers_pb2.StringValue( value=old_password), )) mock.assert_called_once() with session_scope() as session: updated_user = session.execute( select(User).where(User.id == user.id)).scalar_one() assert not updated_user.has_password
def SetPassword(self, request, context): with session_scope() as session: user = session.execute( select(User).where(User.id == context.user_id)).scalar_one() # this is important so anybody can't just set your password through the jail API if user.has_password: context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.ALREADY_HAS_PASSWORD) abort_on_invalid_password(request.new_password, context) user.hashed_password = hash_password(request.new_password) session.commit() send_password_changed_email(user) return self._get_jail_info(user)
def test_GetAccountInfo(db, fast_passwords): # without password user1, token1 = generate_user(hashed_password=None, email="*****@*****.**") with account_session(token1) as account: res = account.GetAccountInfo(empty_pb2.Empty()) assert res.login_method == account_pb2.GetAccountInfoRes.LoginMethod.MAGIC_LINK assert not res.has_password assert res.email == "*****@*****.**" # with password user1, token1 = generate_user(hashed_password=hash_password(random_hex()), email="*****@*****.**") with account_session(token1) as account: res = account.GetAccountInfo(empty_pb2.Empty()) assert res.login_method == account_pb2.GetAccountInfoRes.LoginMethod.PASSWORD assert res.has_password assert res.email == "*****@*****.**"
def ChangePassword(self, request, context): """ Changes the user's password. They have to confirm their old password just in case. If they didn't have an old password previously, then we don't check that. """ with session_scope() as session: user = session.execute( select(User).where(User.id == context.user_id)).scalar_one() if not request.HasField("old_password") and not request.HasField( "new_password"): context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MISSING_BOTH_PASSWORDS) _check_password(user, "old_password", request, context) # password correct or no password if not request.HasField("new_password"): # the user wants to unset their password user.hashed_password = None else: abort_on_invalid_password(request.new_password.value, context) user.hashed_password = hash_password( request.new_password.value) session.commit() send_password_changed_email(user) notify( user_id=user.id, topic="password", key="", action="change", icon="wrench", title=f"Your password was changed", link=urls.account_settings_link(), ) return empty_pb2.Empty()
def test_ChangeEmail_wrong_email(db, fast_passwords): password = random_hex() new_email = f"{random_hex()}@couchers.org.invalid" user, token = generate_user(hashed_password=hash_password(password)) with account_session(token) as account: with pytest.raises(grpc.RpcError) as e: account.ChangeEmail( account_pb2.ChangeEmailReq( password=wrappers_pb2.StringValue(value="wrong password"), new_email=new_email, )) assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT assert e.value.details() == errors.INVALID_USERNAME_OR_PASSWORD with session_scope() as session: assert (session.execute( select(func.count()).select_from(User).where( User.new_email_token_created <= func.now()).where( User.new_email_token_expiry >= func.now())) ).scalar_one() == 0