def change_password_with_old_password(db: DatabaseHandler, email: str, old_password: str, new_password: str, new_password_repeat: str) -> None: """Change password by entering old password.""" email = decode_object_from_bytes_if_needed(email) old_password = decode_object_from_bytes_if_needed(old_password) new_password = decode_object_from_bytes_if_needed(new_password) new_password_repeat = decode_object_from_bytes_if_needed( new_password_repeat) # Check if user exists try: user_info(db=db, email=email) except Exception: raise McAuthChangePasswordException( 'User with email address "%s" does not exist.' % email) if old_password == new_password: raise McAuthChangePasswordException( 'Old and new passwords are the same.') # Validate old password; fetch the hash from the database again because that hash might be outdated (e.g. if the # password has been changed already) db_password_old = db.query( """ SELECT auth_users_id, email, password_hash FROM auth_users WHERE email = %(email)s LIMIT 1 """, { 'email': email }).hash() if db_password_old is None or len(db_password_old) == 0: raise McAuthChangePasswordException( 'Unable to find the user in the database.') # Validate the password try: login_with_email_password(db=db, email=email, password=old_password) except Exception as ex: raise McAuthChangePasswordException( "Unable to log in with old password: %s" % str(ex)) # Execute the change try: change_password(db=db, email=email, new_password=new_password, new_password_repeat=new_password_repeat) except Exception as ex: raise McAuthChangePasswordException("Unable to change password: %s" % str(ex))
def test_login_with_api_key_inactive_user(self): """Inactive user logging in with API key.""" db = connect_to_db() email = '*****@*****.**' password = '******' full_name = 'Test user login' ip_address = '1.2.3.4' add_user( db=db, new_user=NewUser( email=email, full_name=full_name, notes='Test test test', role_ids=[1], active=False, password=password, password_repeat=password, activation_url='https://activate.com/activate', ), ) user = user_info(db=db, email=email) assert user global_api_key = user.global_api_key() with pytest.raises(McAuthLoginException) as ex: login_with_api_key(db=db, api_key=global_api_key, ip_address=ip_address) # Make sure the error message explicitly states that login failed due to user not being active assert 'not active' in str(ex)
def send_user_activation_token(db: DatabaseHandler, email: str, activation_link: str) -> None: """Prepare for activation by emailing the activation token.""" email = decode_object_from_bytes_if_needed(email) activation_link = decode_object_from_bytes_if_needed(activation_link) # Check if user exists try: user = user_info(db=db, email=email) full_name = user.full_name() except Exception as ex: log.warning("Unable to fetch user profile for user '%s': %s" % (email, str(ex),)) full_name = 'Nonexistent user' # If user was not found, send an email to a random address anyway to avoid timing attack full_activation_link = _generate_user_activation_token(db=db, email=email, activation_link=activation_link) if not full_activation_link: log.warning("Unable to generate full activation link for email '%s'" % email) email = '*****@*****.**' full_activation_link = 'activation link' message = AuthActivationNeededMessage( to=email, full_name=full_name, activation_url=full_activation_link, ) if not send_email(message): raise McAuthRegisterException('The user was created, but I was unable to send you an activation email.')
def change_password_with_reset_token(db: DatabaseHandler, email: str, password_reset_token: str, new_password: str, new_password_repeat: str) -> None: """Change password with a password token sent by email.""" email = decode_object_from_bytes_if_needed(email) password_reset_token = decode_object_from_bytes_if_needed( password_reset_token) new_password = decode_object_from_bytes_if_needed(new_password) new_password_repeat = decode_object_from_bytes_if_needed( new_password_repeat) if not password_reset_token: raise McAuthChangePasswordException('Password reset token is empty.') # Check if user exists try: user_info(db=db, email=email) except Exception: raise McAuthChangePasswordException( 'User with email address "%s" does not exist.' % email) # Validate the token once more (was pre-validated in controller) if not password_reset_token_is_valid( db=db, email=email, password_reset_token=password_reset_token): raise McAuthChangePasswordException('Password reset token is invalid.') # Execute the change try: change_password(db=db, email=email, new_password=new_password, new_password_repeat=new_password_repeat) except Exception as ex: raise McAuthChangePasswordException("Unable to change password: %s" % str(ex)) # Unset the password reset token db.query( """ UPDATE auth_users SET password_reset_token_hash = NULL WHERE email = %(email)s """, {'email': email})
def delete_user(db: DatabaseHandler, email: str) -> None: """Delete user.""" email = decode_object_from_bytes_if_needed(email) if not email: raise McAuthProfileException('Email address is empty.') # Check if user exists try: user_info(db=db, email=email) except Exception: raise McAuthProfileException("User with email address '%s' does not exist." % email) # Delete the user (PostgreSQL's relation will take care of 'auth_users_roles_map') db.query(""" DELETE FROM auth_users WHERE email = %(email)s """, {'email': email})
def regenerate_api_key(db: DatabaseHandler, email: str) -> None: """Regenerate API key -- creates new non-IP limited API key, removes all IP-limited API keys.""" email = decode_object_from_bytes_if_needed(email) if not email: raise McAuthProfileException('Email address is empty.') # Check if user exists try: user = user_info(db=db, email=email) except Exception: raise McAuthProfileException( "User with email address '%s' does not exist." % email) db.begin() # Purge all IP-limited API keys db.query( """ DELETE FROM auth_user_api_keys WHERE ip_address IS NOT NULL AND auth_users_id = ( SELECT auth_users_id FROM auth_users WHERE email = %(email)s ) """, {'email': email}) # Regenerate non-IP limited API key db.query( """ UPDATE auth_user_api_keys -- DEFAULT points to a generation function SET api_key = DEFAULT WHERE ip_address IS NULL AND auth_users_id = ( SELECT auth_users_id FROM auth_users WHERE email = %(email)s ) """, {'email': email}) message = AuthAPIKeyResetMessage(to=email, full_name=user.full_name()) if not send_email(message): db.rollback() raise McAuthProfileException( "Unable to send email about reset API key.") db.commit()
def regenerate_api_key(db: DatabaseHandler, email: str) -> None: """Regenerate API key -- creates new non-IP limited API key, removes all IP-limited API keys.""" email = decode_object_from_bytes_if_needed(email) if not email: raise McAuthProfileException('Email address is empty.') # Check if user exists try: user = user_info(db=db, email=email) except Exception: raise McAuthProfileException( "User with email address '%s' does not exist." % email) db.begin() # Purge all API keys db.query( """ DELETE FROM auth_user_api_keys WHERE auth_users_id = %(auth_users_id)s """, {'auth_users_id': user.user_id()}) # Regenerate non-IP limited API key db.query( """ INSERT INTO auth_user_api_keys ( auth_users_id, api_key, ip_address ) VALUES ( %(auth_users_id)s, -- DEFAULT points to a generation function DEFAULT, NULL ) """, {'auth_users_id': user.user_id()}) message = AuthAPIKeyResetMessage(to=email, full_name=user.full_name()) if not send_email(message): db.rollback() raise McAuthProfileException( "Unable to send email about reset API key.") db.commit()
def test_user_info(): db = connect_to_db() email = '*****@*****.**' full_name = 'Test user info' notes = 'Test test test' weekly_requests_limit = 123 weekly_requested_items_limit = 456 max_topic_stories = 789 add_user( db=db, new_user=NewUser( email=email, full_name=full_name, has_consented=True, notes=notes, role_ids=[1], active=True, password='******', password_repeat='user_info', activation_url='', # user is active, no need for activation URL resource_limits=Resources( weekly_requests=weekly_requests_limit, weekly_requested_items=weekly_requested_items_limit, max_topic_stories=max_topic_stories, ), ), ) user = user_info(db=db, email=email) assert isinstance(user, CurrentUser) assert user.user_id() assert user.email() == email assert user.full_name() == full_name assert user.notes() == notes assert user.resource_limits() assert user.resource_limits().weekly_requests() == weekly_requests_limit assert user.resource_limits().weekly_requested_items( ) == weekly_requested_items_limit assert user.resource_limits().max_topic_stories() == max_topic_stories assert user.active() assert user.has_consented() assert user.created_date() assert __looks_like_iso8601_date(user.created_date()) assert user.global_api_key() assert user.password_hash() assert user.has_role('admin')
def activate_user_via_token(db: DatabaseHandler, email: str, activation_token: str) -> None: """Change password with a password token sent by email.""" email = decode_object_from_bytes_if_needed(email) activation_token = decode_object_from_bytes_if_needed(activation_token) if not email: raise McAuthRegisterException("Email is empty.") if not activation_token: raise McAuthRegisterException('Password reset token is empty.') # Validate the token once more (was pre-validated in controller) if not password_reset_token_is_valid( db=db, email=email, password_reset_token=activation_token): raise McAuthRegisterException('Activation token is invalid.') db.begin() # Set the password hash db.query( """ UPDATE auth_users SET active = TRUE WHERE email = %(email)s """, {'email': email}) # Unset the password reset token db.query( """ UPDATE auth_users SET password_reset_token_hash = NULL WHERE email = %(email)s """, {'email': email}) user = user_info(db=db, email=email) message = AuthActivatedMessage(to=email, full_name=user.full_name()) if not send_email(message): db.rollback() raise McAuthRegisterException( "Unable to send email about an activated user.") db.commit()
def all_users(db: DatabaseHandler) -> List[CurrentUser]: """Fetch and return a list of users and their roles.""" # Start a transaction so that the list of users doesn't change while we run separate queries with user_info() db.begin() user_emails = db.query(""" SELECT email FROM auth_users ORDER BY auth_users_id """).flat() users = [] for email in user_emails: users.append(user_info(db=db, email=email)) db.commit() return users
def login_with_email_password(db: DatabaseHandler, email: str, password: str, ip_address: str = None) -> CurrentUser: """Log in with username and password; raise on unsuccessful login.""" email = decode_object_from_bytes_if_needed(email) password = decode_object_from_bytes_if_needed(password) if not (email and password): raise McAuthLoginException("Email and password must be defined.") # Try-except block because we don't want to reveal the specific reason why the login has failed try: user = user_info(db=db, email=email) # Check if user has tried to log in unsuccessfully before and now is trying # again too fast if __user_is_trying_to_login_too_soon(db=db, email=email): raise McAuthLoginException( "User '%s' is trying to log in too soon after the last unsuccessful attempt." % email ) if not password_hash_is_valid(password_hash=user.password_hash(), password=password): raise McAuthLoginException("Password for user '%s' is invalid." % email) except Exception as ex: log.info( "Login failed for %(email)s, will delay any successive login attempt for %(delay)d seconds: %(exc)s" % { 'email': email, 'delay': __POST_UNSUCCESSFUL_LOGIN_DELAY, 'exc': str(ex), } ) # Set the unsuccessful login timestamp # (TIMESTAMP 'now' returns "current transaction's start time", so using LOCALTIMESTAMP instead) db.query(""" UPDATE auth_users SET last_unsuccessful_login_attempt = LOCALTIMESTAMP WHERE email = %(email)s """, {'email': email}) # It might make sense to time.sleep() here for the duration of $POST_UNSUCCESSFUL_LOGIN_DELAY seconds to prevent # legitimate users from trying to log in too fast. However, when being actually brute-forced through multiple # HTTP connections, this approach might end up creating a lot of processes that would time.sleep() and take up # memory. # # So, let's return the error page ASAP and hope that a legitimate user won't be able to reenter his / her # password before the $POST_UNSUCCESSFUL_LOGIN_DELAY amount of seconds pass. # Don't give out a specific reason for the user to not be able to find # out which user emails are registered raise McAuthLoginException("User '%s' was not found or password is incorrect." % email) if not user.active(): raise McAuthLoginException("User with email '%s' is not active." % email) # Reset password reset token (if any) db.query(""" UPDATE auth_users SET password_reset_token_hash = NULL WHERE email = %(email)s AND password_reset_token_hash IS NOT NULL """, {'email': email}) if ip_address: if not user.api_key_for_ip_address(ip_address): db.create( table='auth_user_api_keys', insert_hash={ 'auth_users_id': user.user_id(), 'ip_address': ip_address, }) # Fetch user again user = user_info(db=db, email=email) if not user.api_key_for_ip_address(ip_address): raise McAuthLoginException("Unable to create per-IP API key for IP %s" % ip_address) return user
def login_with_api_key(db: DatabaseHandler, api_key: str, ip_address: str) -> CurrentUser: """Fetch user object for the API key. Only active users are fetched.""" api_key = decode_object_from_bytes_if_needed(api_key) ip_address = decode_object_from_bytes_if_needed(ip_address) if not api_key: raise McAuthLoginException("API key is undefined.") if not ip_address: # Even if provided API key is the global one, we want the IP address raise McAuthLoginException("IP address is undefined.") api_key_user = db.query(""" SELECT auth_users.email FROM auth_users INNER JOIN auth_user_api_keys ON auth_users.auth_users_id = auth_user_api_keys.auth_users_id WHERE ( auth_user_api_keys.api_key = %(api_key)s AND ( auth_user_api_keys.ip_address IS NULL OR auth_user_api_keys.ip_address = %(ip_address)s ) ) GROUP BY auth_users.auth_users_id, auth_users.email ORDER BY auth_users.auth_users_id LIMIT 1 """, { 'api_key': api_key, 'ip_address': ip_address, }).hash() if api_key_user is None or len(api_key_user) == 0: raise McAuthLoginException("Unable to find user for API key '%s' and IP address '%s'" % (api_key, ip_address,)) email = api_key_user['email'] # Check if user has tried to log in unsuccessfully before and now is trying again too fast if __user_is_trying_to_login_too_soon(db=db, email=email): raise McAuthLoginException( "User '%s' is trying to log in too soon after the last unsuccessful attempt." % email ) user = user_info(db=db, email=email) # Reset password reset token (if any) db.query(""" UPDATE auth_users SET password_reset_token_hash = NULL WHERE email = %(email)s AND password_reset_token_hash IS NOT NULL """, {'email': email}) if not user.active(): raise McAuthLoginException("User '%s' for API key '%s' is not active." % (email, api_key,)) return user
def update_user(db: DatabaseHandler, user_updates: ModifyUser) -> None: """Update an existing user.""" if not user_updates: raise McAuthProfileException("Existing user is undefined.") # Check if user exists try: user = user_info(db=db, email=user_updates.email()) except Exception: raise McAuthProfileException('User with email address "%s" does not exist.' % user_updates.email()) db.begin() if user_updates.full_name() is not None: db.query(""" UPDATE auth_users SET full_name = %(full_name)s WHERE email = %(email)s """, { 'full_name': user_updates.full_name(), 'email': user_updates.email(), }) if user_updates.notes() is not None: db.query(""" UPDATE auth_users SET notes = %(notes)s WHERE email = %(email)s """, { 'notes': user_updates.notes(), 'email': user_updates.email(), }) if user_updates.active() is not None: db.query(""" UPDATE auth_users SET active = %(active)s WHERE email = %(email)s """, { 'active': bool(int(user_updates.active())), 'email': user_updates.email(), }) if user_updates.has_consented() is not None: db.query(""" UPDATE auth_users SET has_consented = %(has_consented)s WHERE email = %(email)s """, { 'has_consented': bool(int(user_updates.has_consented())), 'email': user_updates.email(), }) if user_updates.password() is not None: try: change_password( db=db, email=user_updates.email(), new_password=user_updates.password(), new_password_repeat=user_updates.password_repeat(), do_not_inform_via_email=True, ) except Exception as ex: db.rollback() raise McAuthProfileException("Unable to change password: %s" % str(ex)) resource_limits = user_updates.resource_limits() if resource_limits: if resource_limits.weekly_requests() is not None: db.query(""" UPDATE auth_user_limits SET weekly_requests_limit = %(weekly_requests_limit)s WHERE auth_users_id = %(auth_users_id)s """, { 'weekly_requests_limit': resource_limits.weekly_requests(), 'auth_users_id': user.user_id(), }) if resource_limits.weekly_requested_items() is not None: db.query(""" UPDATE auth_user_limits SET weekly_requested_items_limit = %(weekly_requested_items_limit)s WHERE auth_users_id = %(auth_users_id)s """, { 'weekly_requested_items_limit': resource_limits.weekly_requested_items(), 'auth_users_id': user.user_id(), }) if resource_limits.max_topic_stories() is not None: db.query(""" UPDATE auth_user_limits SET max_topic_stories = %(max_topic_stories)s WHERE auth_users_id = %(auth_users_id)s """, { 'max_topic_stories': resource_limits.max_topic_stories(), 'auth_users_id': user.user_id(), }) if user_updates.role_ids() is not None: db.query(""" DELETE FROM auth_users_roles_map WHERE auth_users_id = %(auth_users_id)s """, {'auth_users_id': user.user_id()}) for auth_roles_id in user_updates.role_ids(): db.insert(table='auth_users_roles_map', insert_hash={ 'auth_users_id': user.user_id(), 'auth_roles_id': auth_roles_id, }) db.commit()
def add_user(db: DatabaseHandler, new_user: NewUser) -> None: """Add new user.""" if not new_user: raise McAuthRegisterException("New user is undefined.") # Check if user already exists user_exists = db.query( """ SELECT auth_users_id FROM auth_users WHERE email = %(email)s LIMIT 1 """, { 'email': new_user.email() }).hash() if user_exists is not None and 'auth_users_id' in user_exists: raise McAuthRegisterException("User with email '%s' already exists." % new_user.email()) # Hash + validate the password try: password_hash = generate_secure_hash(password=new_user.password()) if not password_hash: raise McAuthRegisterException("Password hash is empty.") except Exception as ex: log.error("Unable to hash a new password: {}".format(ex)) raise McAuthRegisterException('Unable to hash a new password.') db.begin() # Create the user db.create(table='auth_users', insert_hash={ 'email': new_user.email(), 'password_hash': password_hash, 'full_name': new_user.full_name(), 'notes': new_user.notes(), 'active': bool(int(new_user.active())), }) # Fetch the user's ID try: user = user_info(db=db, email=new_user.email()) except Exception as ex: db.rollback() raise McAuthRegisterException( "I've attempted to create the user but it doesn't exist: %s" % str(ex)) # Create roles try: for auth_roles_id in new_user.role_ids(): db.create(table='auth_users_roles_map', insert_hash={ 'auth_users_id': user.user_id(), 'auth_roles_id': auth_roles_id, }) except Exception as ex: raise McAuthRegisterException("Unable to create roles: %s" % str(ex)) # Update limits (if they're defined) if new_user.weekly_requests_limit() is not None: db.query( """ UPDATE auth_user_limits SET weekly_requests_limit = %(weekly_requests_limit)s WHERE auth_users_id = %(auth_users_id)s """, { 'auth_users_id': user.user_id(), 'weekly_requests_limit': new_user.weekly_requests_limit(), }) if new_user.weekly_requested_items_limit() is not None: db.query( """ UPDATE auth_user_limits SET weekly_requested_items_limit = %(weekly_requested_items_limit)s WHERE auth_users_id = %(auth_users_id)s """, { 'auth_users_id': user.user_id(), 'weekly_requested_items_limit': new_user.weekly_requested_items_limit(), }) # Subscribe to newsletter if new_user.subscribe_to_newsletter(): db.create(table='auth_users_subscribe_to_newsletter', insert_hash={'auth_users_id': user.user_id()}) if not new_user.active(): send_user_activation_token( db=db, email=new_user.email(), activation_link=new_user.activation_url(), subscribe_to_newsletter=new_user.subscribe_to_newsletter(), ) db.commit()
def change_password(db: DatabaseHandler, email: str, new_password: str, new_password_repeat: str, do_not_inform_via_email: bool = False) -> None: """Change user's password.""" email = decode_object_from_bytes_if_needed(email) new_password = decode_object_from_bytes_if_needed(new_password) new_password_repeat = decode_object_from_bytes_if_needed( new_password_repeat) if isinstance(do_not_inform_via_email, bytes): do_not_inform_via_email = decode_object_from_bytes_if_needed( do_not_inform_via_email) do_not_inform_via_email = bool(int(do_not_inform_via_email)) # Check if user exists try: user = user_info(db=db, email=email) except Exception: raise McAuthChangePasswordException( 'User with email address "%s" does not exist.' % email) password_validation_message = validate_new_password( email=email, password=new_password, password_repeat=new_password_repeat) if password_validation_message: raise McAuthChangePasswordException("Unable to change password: %s" % password_validation_message) # Hash + validate the password try: password_new_hash = generate_secure_hash(password=new_password) except Exception as ex: raise McAuthChangePasswordException( "Unable to hash a new password: %s" % str(ex)) if not password_new_hash: raise McAuthChangePasswordException( "Generated password hash is empty.") # Set the password hash db.query( """ UPDATE auth_users SET password_hash = %(password_hash)s, active = TRUE WHERE email = %(email)s """, { 'email': email, 'password_hash': password_new_hash, }) if not do_not_inform_via_email: message = AuthPasswordChangedMessage(to=email, full_name=user.full_name()) if not send_email(message): raise McAuthChangePasswordException( 'The password has been changed, but I was unable to send an email notifying you about the change.' )