Source code for authnzerver.actions.user

# -*- coding: utf-8 -*-
# actions_user.py - Waqas Bhatti (wbhatti@astro.princeton.edu) - Aug 2018
# License: MIT - see the LICENSE file for the full text.

"""This contains functions to drive user account related auth actions.

"""

#############
## LOGGING ##
#############

import logging
from types import SimpleNamespace

# get a logger
LOGGER = logging.getLogger(__name__)


#############
## IMPORTS ##
#############

try:

    from datetime import datetime, timezone, timedelta

    utc = timezone.utc

except Exception:

    from datetime import datetime, timedelta, tzinfo

    ZERO = timedelta(0)

    class UTC(tzinfo):
        """UTC"""

        def utcoffset(self, dt):
            return ZERO

        def tzname(self, dt):
            return "UTC"

        def dst(self, dt):
            return ZERO

    utc = UTC()

import multiprocessing as mp
import uuid

from sqlalchemy import select, insert
from argon2 import PasswordHasher
from tornado.escape import xhtml_escape, squeeze

from ..permissions import pii_hash

from .. import validators

from .passwords import validate_input_password
from authnzerver.actions.utils import get_procdb_permjson

######################
## PASSWORD CONTEXT ##
######################

pass_hasher = PasswordHasher()


###################
## USER HANDLING ##
###################


[docs]def create_new_user( payload: dict, min_pass_length: int = 12, max_unsafe_similarity: int = 33, override_authdb_path: str = None, raiseonfail: bool = False, config: SimpleNamespace = None, ) -> dict: """Makes a new user. Parameters ---------- payload : dict This is a dict with the following required keys: - full_name: str. Full name for the user - email: str. User's email address - password: str. User's password. Optional payload items include: - extra_info: dict. optional dict to add any extra info for this user, will be stored as JSON in the DB - verify_retry_wait: int, default: 6. This sets the amount of time in hours a user must wait before retrying a failed verification action, i.e., responding before expiry of and with the correct verification token. - system_id: str. If this is provided, must be a unique string that will serve as the system_id for the user. This ID is safe to share with client JS, etc., as opposed to the user_id primary key for the user. If not provided, a UUIDv4 will be generated and used for the system_id. - public_suffix_list: list of str. If this is provided as a payload item, it must be a list of domain name suffixes sources from the Mozilla Public Suffix list: https://publicsuffix.org/list/. This is used to check if the full name of the user may possibly be a spam link intended to be used when the authnzerver emails out verification tokens for new users. If the full name contains a suffix in this list, the user creation request will fail. If this item is not provided in the payload, this function will look up the current process's namespace to see if it was loaded there and use it from there if so. If the public suffix list can't be found in either item, new user creation will fail. In addition to these items received from an authnzerver client, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. min_pass_length : int The minimum required character length of the password. max_unsafe_similarity : int The maximum ratio required to fuzzy-match the input password against the server's domain name, the user's email, or their name. config : SimpleNamespace object or None An object containing systemwide config variables as attributes. This is useful when the wrapping function needs to pass in some settings directly from environment variables. Returns ------- dict Returns a dict with the user's user_id and user_email, and a boolean for send_verification. Notes ----- If the email address already exists in the database, then either the user has forgotten that they have an account or someone else is being annoying. In this case, if is_active is True, we'll tell the user that we've sent an email but won't do anything. If is_active is False and emailverify_sent_datetime is at least *payload['verify_retry_wait']* hours in the past, we'll send a new email verification email and update the emailverify_sent_datetime. In this case, we'll just tell the user that we've sent the email but won't tell them if their account exists. Only after the user verifies their email, is_active will be set to True and user_role will be set to 'authenticated'. """ engine, meta, permjson, dbpath = get_procdb_permjson( override_authdb_path=override_authdb_path, override_permissions_json=None, raiseonfail=raiseonfail, ) for key in ("reqid", "pii_salt"): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "messages": ["Invalid user creation request."], } for key in ("full_name", "email", "password"): if key not in payload: LOGGER.error( "[%s] Invalid user creation request, missing %s." % (payload["reqid"], key) ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "messages": ["Invalid user creation request."], } # # validate the email provided # # check for Unicode confusables and dangerous usernames email_confusables_ok = validators.validate_confusables_email( payload["email"] ) # check if the email is a valid one according to HTML5 specs email_regex_ok = validators.validate_email_address(payload["email"]) # check if the email domain is not a disposable email address if email_confusables_ok and email_regex_ok: email_domain = payload["email"].split("@")[1].casefold() email_domain_not_disposable = ( email_domain not in validators.DISPOSABLE_EMAIL_DOMAINS ) else: email_domain_not_disposable = False # if all of the tests above pass, the email is OK email_ok = ( email_regex_ok and email_confusables_ok and email_domain_not_disposable ) if not email_ok: LOGGER.error( "[%s] User creation request failed for " "email: %s. " "The email address provided is not valid." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), ) ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": "invalid email", "messages": [ "The email address provided doesn't " "seem to be a valid email address and cannot be used " "to sign up for an account on this server." ], } email = validators.normalize_value(payload["email"]) full_name = validators.normalize_value( payload["full_name"], casefold=False ) # sanitize the full name full_name = squeeze(xhtml_escape(full_name)) if "http" in full_name.casefold() or "://" in full_name: LOGGER.error( f"[{payload['reqid']}] Full name provided contains " f"a link or is close to one: {full_name} " f"and is likely suspicious." ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": "invalid full name", "messages": [ "The full name provided appears to contain " "an HTTP link, and cannot be used " "to sign up for an account on this server." ], } # check if the full name contains a valid public suffix domain # it's probably suspicious if so currproc = mp.current_process() public_suffix_list = getattr(currproc, "public_suffix_list", None) if not public_suffix_list: public_suffix_list = payload.get("public_suffix_list", None) if not public_suffix_list: LOGGER.error( f"[{payload['reqid']}] Could not validate full name " f"because the public suffix list is not provided in " f"either the payload or in the current process namespace." ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": "public suffix list not present", "messages": [ "Full name could not be validated " "because of an internal server error" ], } for domain_suffix in public_suffix_list: if domain_suffix in full_name.casefold(): LOGGER.error( f"[{payload['reqid']}] Full name provided contains " f"a link or is close to one: {full_name} " f"and is likely suspicious." ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": "invalid full name", "messages": [ "The full name provided appears to contain " "an HTTP link, and cannot be used " "to sign up for an account on this server." ], } # get the password password = payload["password"] # # optional items # # 1. get extra info if any extra_info = payload.get("extra_info", None) # 2. get the verify_retry_wait time verify_retry_wait = payload.get("verify_retry_wait", 6) try: verify_retry_wait = int(verify_retry_wait) except Exception: verify_retry_wait = 6 if verify_retry_wait < 1: verify_retry_wait = 1 # 3. generate or get a system_id for this user if "system_id" in payload and isinstance(payload["system_id"], str): system_id = payload["system_id"] else: system_id = str(uuid.uuid4()) # # proceed to processing # users = meta.tables["users"] # the password is restricted to 256 characters since that should be enough # (for 2020), and we don't want to kill our own server when hashing absurdly # long passwords through Argon2-id. input_password = password[:256] # hash the user's password hashed_password = pass_hasher.hash(input_password) # validate the input password to see if it's OK # do this here to make sure the password hash completes at least once passok, messages = validate_input_password( full_name, email, input_password, payload["pii_salt"], payload["reqid"], min_pass_length=min_pass_length, max_unsafe_similarity=max_unsafe_similarity, config=config, ) if not passok: LOGGER.error( "[%s] User creation request failed for " "email: %s. " "The password provided is not secure." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), ) ) return { "success": False, "user_email": email, "user_id": None, "send_verification": False, "failure_reason": "invalid password", "messages": messages, } # insert stuff into the user's table, set is_active = False, user_role = # 'locked', the emailverify_sent_datetime to datetime.utcnow() new_user_dict = None try: if not extra_info: extra_info = { "provenance": "request-created", "type": "normal-user", "verify_retry_wait": verify_retry_wait, } else: extra_info.update( { "provenance": "request-created", "type": "normal-user", "verify_retry_wait": verify_retry_wait, } ) new_user_dict = { "full_name": full_name, "system_id": system_id, "password": hashed_password, "email": email, "email_verified": False, "is_active": False, "emailverify_sent_datetime": datetime.utcnow(), "created_on": datetime.utcnow(), "user_role": "locked", "last_updated": datetime.utcnow(), "extra_info": extra_info, } with engine.begin() as conn: ins = insert(users).values(new_user_dict) conn.execute(ins) user_added = True # this will catch stuff like people trying to sign up again with their email # address except Exception: user_added = False with engine.begin() as conn: # get back the user ID sel = ( select( users.c.email, users.c.user_id, users.c.system_id, users.c.is_active, users.c.emailverify_sent_datetime, ) .select_from(users) .where(users.c.email == email) ) result = conn.execute(sel) rows = result.first() # if the user was added successfully, tell the frontend all is good and to # send a verification email if user_added and rows: LOGGER.info( "[%s] User creation request succeeded for " "email: %s. New user_id: %s" % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(rows.user_id, payload["pii_salt"]), ) ) messages.append( "User account created. Please verify your email address to log in." ) return { "success": True, "user_email": rows.email, "user_id": rows.user_id, "system_id": rows.system_id, "send_verification": True, "messages": messages, } # if the user wasn't added successfully, then they exist in the DB already elif (not user_added) and rows: LOGGER.error( "[%s] User creation request failed for " "email: %s. " "The email provided probably exists in the DB already. " % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), ) ) # check the timedelta between now and the emailverify_sent_datetime verification_timedelta = ( datetime.utcnow() - rows.emailverify_sent_datetime ) # this sets whether we should resend the verification email resend_verification = (not rows.is_active) and ( verification_timedelta > timedelta(hours=verify_retry_wait) ) LOGGER.warning( "[%s] Existing user_id = %s for new user creation " "request with email = %s, is_active = %s. " "Email verification originally sent at = %sZ, " "verification timedelta: %s, verify_retry_wait = %s hours. " "Will resend verification = %s" % ( payload["reqid"], pii_hash(rows.user_id, payload["pii_salt"]), pii_hash(payload["email"], payload["pii_salt"]), rows.is_active, rows.emailverify_sent_datetime.isoformat(), verification_timedelta, verify_retry_wait, resend_verification, ) ) if resend_verification: # if we're going to resend the verification, update the users table # with the latest info sent by the user (they might've changed their # password in the meantime) if new_user_dict is not None: del new_user_dict["created_on"] del new_user_dict["system_id"] with engine.begin() as conn: upd = ( users.update() .where(users.c.user_id == rows.user_id) .values(new_user_dict) ) conn.execute(upd) # get back the user ID sel = ( select( users.c.email, users.c.user_id, users.c.system_id, users.c.is_active, users.c.emailverify_sent_datetime, ) .select_from(users) .where(users.c.email == email) ) result = conn.execute(sel) rows = result.first() LOGGER.warning( "[%s] Resending verification to user: %s because timedelta " "between original sign up and retry: %s > " "verify_retry_wait: %s hours. " "User information has been updated " "with their latest provided sign-up info." % ( payload["reqid"], pii_hash(rows.user_id, payload["pii_salt"]), verification_timedelta, verify_retry_wait, ) ) messages.append( "User account created. Please verify your email address to log in." ) return { "success": False, "user_email": rows.email, "user_id": rows.user_id, "system_id": rows.system_id, "send_verification": resend_verification, "failure_reason": "user exists", "messages": messages, } # otherwise, the user wasn't added successfully and they don't already exist # in the database so something else went wrong. else: LOGGER.error( "[%s] User creation request failed for email: %s. " "Could not add row to the DB." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), ) ) messages.append( "User account created. Please verify your email address to log in." ) return { "success": False, "user_email": None, "user_id": None, "send_verification": False, "failure_reason": "DB issue with user creation", "messages": messages, }
[docs]def internal_delete_user( payload: dict, raiseonfail: bool = False, override_authdb_path: str = None, config: SimpleNamespace = None, ) -> dict: """Deletes a user and does not check for permissions. Suitable ONLY for internal server use by a frontend. Do NOT expose this function to an end user. Parameters ---------- payload : dict This is a dict with the following required keys: - target_userid: int In addition to these items received from an authnzerver client, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. config : SimpleNamespace object or None An object containing systemwide config variables as attributes. This is useful when the wrapping function needs to pass in some settings directly from environment variables. Returns ------- dict Returns a dict containing a success key indicating if the user was deleted. """ engine, meta, permjson, dbpath = get_procdb_permjson( override_authdb_path=override_authdb_path, override_permissions_json=None, raiseonfail=raiseonfail, ) for key in ("reqid", "pii_salt"): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { "success": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "messages": ["Invalid user deletion request."], } if "target_userid" not in payload: LOGGER.error( "[%s] Invalid user deletion request, missing %s." % (payload["reqid"], "target_userid") ) return { "success": False, "failure_reason": ( "invalid request: missing 'target_userid' in request" ), "messages": ["Invalid user deletion request."], } with engine.begin() as conn: users = meta.tables["users"] sessions = meta.tables["sessions"] # delete the user delete = users.delete().where( users.c.user_id == payload["target_userid"] ) conn.execute(delete) # don't forget to delete their sessions as well delete = sessions.delete().where( sessions.c.user_id == payload["target_userid"] ) conn.execute(delete) sel = ( select(users.c.user_id, users.c.email, sessions.c.session_token) .select_from(users.join(sessions)) .where(users.c.user_id == payload["target_userid"]) ) result = conn.execute(sel) rows = result.fetchall() if rows and len(rows) > 0: LOGGER.error( "[%s] User deletion request failed for " "user_id: %s. " "The database rows for this user could not be deleted." % ( payload["reqid"], pii_hash(payload["target_userid"], payload["pii_salt"]), ) ) return { "success": False, "failure_reason": "user deletion failed in DB", "messages": ["Could not delete user from DB."], } else: LOGGER.warning( "[%s] User deletion request succeeded for " "user_id: %s. " % ( payload["reqid"], pii_hash(payload["target_userid"], payload["pii_salt"]), ) ) return { "success": True, "user_id": payload["target_userid"], "messages": ["User successfully deleted from DB."], }
[docs]def delete_user( payload: dict, raiseonfail: bool = False, override_authdb_path: str = None, config: SimpleNamespace = None, ) -> dict: """Deletes a user. This can only be called by the user themselves or the superuser. FIXME: does this actually check if it's called by the right user? FIXME: add check_permissions to this to make more robust This will also immediately invalidate all sessions corresponding to the target user. Superuser accounts cannot be deleted. Parameters ---------- payload : dict This is a dict with the following required keys: - email: str - user_id: int - password: str In addition to these items received from an authnzerver client, the payload must also include the following keys (usually added in by a wrapping function): - reqid: int or str - pii_salt: str override_authdb_path : str or None If given as a str, is the alternative path to the auth DB. raiseonfail : bool If True, will raise an Exception if something goes wrong. config : SimpleNamespace object or None An object containing systemwide config variables as attributes. This is useful when the wrapping function needs to pass in some settings directly from environment variables. Returns ------- dict Returns a dict containing a success key indicating if the user was deleted. """ engine, meta, permjson, dbpath = get_procdb_permjson( override_authdb_path=override_authdb_path, override_permissions_json=None, raiseonfail=raiseonfail, ) for key in ("reqid", "pii_salt"): if key not in payload: LOGGER.error( "Missing %s in payload dict. Can't process this request." % key ) return { "success": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "email": None, "user_id": None, "messages": ["Invalid user deletion request."], } for key in ("email", "user_id", "password"): if key not in payload: LOGGER.error( "[%s] Invalid user deletion request, missing %s." % (payload["reqid"], key) ) return { "success": False, "failure_reason": ( "invalid request: missing '%s' in request" % key ), "user_id": None, "email": None, "messages": ["Invalid user deletion request."], } users = meta.tables["users"] sessions = meta.tables["sessions"] # check if the incoming email address actually belongs to the user making # the request sel = ( select( users.c.user_id, users.c.email, users.c.password, users.c.user_role ) .select_from(users) .where(users.c.user_id == payload["user_id"]) .where(users.c.email == payload["email"]) ) with engine.begin() as conn: result = conn.execute(sel) row = result.first() if not row or len(row) == 0: LOGGER.error( "[%s] User deletion request failed for " "email: %s, user_id: %s. " "The email address provided does not match the one on record." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(payload["user_id"], payload["pii_salt"]), ) ) return { "success": False, "failure_reason": "invalid email", "user_id": payload["user_id"], "email": payload["email"], "messages": [ "We could not verify your email address or password." ], } # check if the user's password is valid and matches the one on record try: pass_ok = pass_hasher.verify( row["password"], payload["password"][:256] ) except Exception as e: LOGGER.error( "[%s] User deletion request failed for " "email: %s, user_id: %s. " "The password provided does not match " "the one on record. Exception: %s" % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(payload["user_id"], payload["pii_salt"]), e, ) ) pass_ok = False if not pass_ok: return { "success": False, "failure_reason": "invalid password", "user_id": payload["user_id"], "email": payload["email"], "messages": [ "We could not verify your email address or password." ], } if row["user_role"] == "superuser": LOGGER.error( "[%s] User deletion request failed for " "email: %s, user_id: %s. " "Superusers can't be deleted." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(payload["user_id"], payload["pii_salt"]), ) ) return { "success": False, "failure_reason": "can't delete superusers", "user_id": payload["user_id"], "email": payload["email"], "messages": ["Can't delete superusers."], } # delete the user with engine.begin() as conn: delete = ( users.delete() .where(users.c.user_id == payload["user_id"]) .where(users.c.email == payload["email"]) .where(users.c.user_role != "superuser") ) result = conn.execute(delete) result.close() # don't forget to delete the sessions as well delete = sessions.delete().where( sessions.c.user_id == payload["user_id"] ) result = conn.execute(delete) result.close() sel = ( select(users.c.user_id, users.c.email, sessions.c.session_token) .select_from(users.join(sessions)) .where(users.c.user_id == payload["user_id"]) ) result = conn.execute(sel) rows = result.fetchall() if rows and len(rows) > 0: LOGGER.error( "[%s] User deletion request failed for " "email: %s, user_id: %s. " "The database rows for this user could not be deleted." % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(payload["user_id"], payload["pii_salt"]), ) ) return { "success": False, "failure_reason": "user deletion failed in DB", "user_id": payload["user_id"], "email": payload["email"], "messages": ["Could not delete user from DB."], } else: LOGGER.warning( "[%s] User deletion request succeeded for " "email: %s, user_id: %s. " % ( payload["reqid"], pii_hash(payload["email"], payload["pii_salt"]), pii_hash(payload["user_id"], payload["pii_salt"]), ) ) return { "success": True, "user_id": payload["user_id"], "email": payload["email"], "messages": ["User successfully deleted from DB."], }