Example #1
0
def database_health_check(
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """
    This function checks if the current process' DB connection is good.

    """

    currproc = mp.current_process()
    currproc_name = currproc.name

    try:

        engine, meta, permjson, dbpath = get_procdb_permjson(
            override_authdb_path=override_authdb_path,
            override_permissions_json=None,
            raiseonfail=raiseonfail,
        )

        users = meta.tables["users"]
        with engine.begin() as conn:
            sel = (select(users.c.user_role).select_from(users).where(
                users.c.user_id == 1))
            result = conn.execute(sel)
            item = result.scalar()

        if item is not None and item == "superuser":
            check = True
            failure_reason = None
            messages = ["Database in process: %s OK." % currproc_name]
        else:
            check = False
            failure_reason = "database broken"
            messages = ["Database has no superuser, probably broken."]

        retdict = {
            "success": check,
            "process": currproc_name,
            "messages": messages,
        }
        if failure_reason is not None:
            retdict["failure_reason"] = failure_reason

        return retdict

    except Exception:

        if raiseonfail:
            raise

        return {
            "success": False,
            "process": currproc_name,
            "failure_reason": "database broken",
            "messages": ["Database access exception, probably broken."],
        }
Example #2
0
def auth_echo(payload: dict):
    """
    This just echoes back the payload.

    """
    currproc = mp.current_process()
    engine, meta, permjson, dbpath = get_procdb_permjson(
        override_authdb_path=None,
        override_permissions_json=None,
        raiseonfail=True,
    )

    with engine.begin() as conn:
        permissions = meta.tables["roles"]
        s = select(permissions)
        result = conn.execute(s)
        rows = result.fetchall()
        # add the result to the outgoing payload
        serializable_result = [dict(row._mapping) for row in rows]
        payload["dbtest"] = serializable_result

    LOGGER.info("responding from process: %s" % currproc.name)
    return payload
Example #3
0
def verify_apikey(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    override_permissions_json: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Checks if an API key is valid.

    This version does not require a session.

    Parameters
    ----------

    payload : dict
        This dict contains a single key:

        - apikey_dict: the decrypted and verified API key info dict from the
          frontend.

        - user_id: the user ID of the person wanting to verify this key.

        - user_role: the user role of the person wanting to verify this key.

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        use. This is used to check if the user_id is allowed to actually verify
        ("read") an API key.

    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
        The dict returned is of the form::

            {'success': True if API key is OK and False otherwise,
             'messages': list of str messages if any}

    """

    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),
                "apikey":
                None,
                "expires":
                None,
                "messages": ["Invalid API key request."],
            }

    for key in ("apikey_dict", "user_id", "user_role"):
        if key not in payload:
            LOGGER.error("[%s] Invalid API key request, missing %s." %
                         (payload["reqid"], key))
            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": ["Some required keys are missing from payload."],
            }

    apikey_dict = payload["apikey_dict"]
    user_id = payload["user_id"]
    user_role = payload["user_role"]

    # check if the user is allowed to read the presented API key
    apikey_verify_allowed = check_user_access(
        {
            "user_id": user_id,
            "user_role": user_role,
            "action": "view",
            "target_name": "apikey",
            "target_owner": apikey_dict["uid"],
            "target_visibility": "private",
            "target_sharedwith": None,
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_permissions_json=override_permissions_json,
        override_authdb_path=override_authdb_path,
    )

    if not apikey_verify_allowed["success"]:

        LOGGER.error("[%s] Invalid API key verification request. "
                     "from user_id: %s, role: %s. The API key presented is "
                     "not readable by this user." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("originating user is not allowed to operate on this API key"),
            "messages": [
                "API key verification failed. "
                "You are not allowed to operate on this API key."
            ],
        }

    apikeys = meta.tables["apikeys_nosession"]

    # the apikey sent to us must match the stored apikey's properties:
    # - token
    # - userid
    # - expired must be in the future
    # - issued must be in the past
    # - not_valid_before must be in the past
    dt_utcnow = datetime.utcnow()

    sel = (select(
        apikeys.c.apikey,
        apikeys.c.expires,
    ).select_from(apikeys).where(apikeys.c.apikey == apikey_dict["tkn"]).where(
        apikeys.c.user_id == apikey_dict["uid"]).where(
            apikeys.c.user_role == apikey_dict["rol"]).where(
                apikeys.c.expires > dt_utcnow).where(
                    apikeys.c.issued < dt_utcnow).where(
                        apikeys.c.not_valid_before < dt_utcnow))

    with engine.begin() as conn:
        result = conn.execute(sel)
        row = result.first()

    if row is not None and len(row) != 0:

        LOGGER.info("[%s] No-session API key verified successfully. "
                    "user_id: %s, role: '%s', audience: '%s', subject: '%s', "
                    "apiversion: %s, expires on: %s" % (
                        payload["reqid"],
                        pii_hash(apikey_dict["uid"], payload["pii_salt"]),
                        apikey_dict["rol"],
                        apikey_dict["aud"],
                        apikey_dict["sub"],
                        apikey_dict["ver"],
                        apikey_dict["exp"],
                    ))

        return {
            "success":
            True,
            "messages":
            [("No-session API key verified successfully. Expires: %s." %
              row.expires.isoformat())],
        }

    else:

        LOGGER.error("[%s] No-session API key verification failed. Failed key "
                     "user_id: %s, role: '%s', audience: '%s', subject: '%s', "
                     "apiversion: %s, expires on: %s" % (
                         payload["reqid"],
                         pii_hash(apikey_dict["uid"], payload["pii_salt"]),
                         apikey_dict["rol"],
                         apikey_dict["aud"],
                         apikey_dict["sub"],
                         apikey_dict["ver"],
                         apikey_dict["exp"],
                     ))

        return {
            "success":
            False,
            "failure_reason":
            ("key validation failed, "
             "provided key does not match stored key or has expired"),
            "messages": ["API key could not be verified."],
        }
Example #4
0
def issue_apikey(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    override_permissions_json: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Issues a new API key.

    This version does not require a session.

    Parameters
    ----------

    payload : dict
        The payload dict must have the following keys:

        - issuer: str, the entity that will be designated as the API key issuer

        - audience: str, the service this API key is being issued for

        - subject: str, the specific API endpoint API key is being issued for

        - apiversion: int or str, the API version that the API key is valid for

        - expires_seconds: int, the number of seconds after which the API key
          expires

        - not_valid_before: float or int, the amount of seconds after utcnow()
          when the API key becomes valid

        - user_id: int, the user ID of the user requesting the API key

        - user_role: str, the user role of the user requesting the API key

        - ip_address: str, the IP address to tie the API key to

        - refresh_expires: int, the number of seconds after which the API key's
          refresh token expires

        - refresh_nbf: float or int, the amount of seconds after utcnow()
          after which the refresh token becomes valid

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        use. This is used to check if the user_id is allowed to actually request
        an API key.

    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
        The dict returned is of the form::

            {'success': True or False,
             'apikey': apikey dict,
             'expires': expiry datetime in ISO format,
             'refresh_token': refresh token str,
             'refresh_token_expires': expiry of refresh token in ISO format,
             'messages': list of str messages if any}

    Notes
    -----

    API keys are tied to an IP address, user ID, and role.

    This function will return a dict with all the API key information. This
    entire dict should be serialized to JSON, encrypted and time-stamp signed by
    the frontend as the final "API key", and finally sent back to the client.

    """

    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),
                "apikey":
                None,
                "expires":
                None,
                "messages": ["Invalid API key request."],
            }

    for key in {
            "user_id",
            "user_role",
            "expires_seconds",
            "not_valid_before",
            "issuer",
            "audience",
            "subject",
            "ip_address",
            "apiversion",
            "refresh_expires",
            "refresh_nbf",
    }:

        if key not in payload:
            LOGGER.error("[%s] Invalid API key request, missing %s." %
                         (payload["reqid"], key))

        if key not in payload:
            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' from request" % key),
                "apikey":
                None,
                "expires":
                None,
                "messages": ["Some required keys are missing from payload."],
            }

    # check if the provided user_id and role can actually create an API key
    user_id = payload["user_id"]
    user_role = payload["user_role"]

    apikey_creation_allowed = check_user_access(
        {
            "user_id": user_id,
            "user_role": user_role,
            "action": "create",
            "target_name": "apikey",
            "target_owner": user_id,
            "target_visibility": "private",
            "target_sharedwith": None,
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_permissions_json=override_permissions_json,
        override_authdb_path=override_authdb_path,
    )

    if not apikey_creation_allowed["success"]:

        LOGGER.error("[%s] Invalid no-session API key issuance request. "
                     "from user_id: %s, role: '%s'. "
                     "The user is not allowed to create an API key." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("originating user is not allowed to issue an API key"),
            "messages": [
                "API key issuance failed. "
                "You are not allowed to issue an API key."
            ],
        }

    #
    # finally, generate the API key
    #
    random_token = secrets.token_urlsafe(32)

    # we'll return this API key dict to the frontend so it can JSON dump it,
    # encode to bytes, then encrypt, then sign it, and finally send back to the
    # client
    issued = datetime.utcnow()
    expires = issued + timedelta(seconds=payload["expires_seconds"])

    notvalidbefore = issued + timedelta(seconds=payload["not_valid_before"])

    apikey_dict = {
        "iss": payload["issuer"],
        "ver": payload["apiversion"],
        "uid": payload["user_id"],
        "rol": payload["user_role"],
        "aud": payload["audience"],
        "sub": payload["subject"],
        "ipa": payload["ip_address"],
        "tkn": random_token,
        "iat": issued.isoformat(),
        "nbf": notvalidbefore.isoformat(),
        "exp": expires.isoformat(),
    }
    apikey_json = json.dumps(apikey_dict)

    # generate the refresh token now
    refresh_token = secrets.token_urlsafe(32)

    # the refresh token is effectively a password, so we'll treat it as such
    hashed_refresh_token = token_hasher.hash(refresh_token)

    # the refresh token expiry and nbf
    refresh_token_expiry = issued + timedelta(
        seconds=payload["refresh_expires"])
    refresh_token_nbf = issued + timedelta(seconds=payload["refresh_nbf"])

    # we'll also store this dict in the apikeys table
    apikeys = meta.tables["apikeys_nosession"]

    # NOTE: we store only the random token. this will later be checked for
    # equality against the value stored in the API key dict['tkn'] when we send
    # in this API key for verification later
    ins = insert(apikeys).values({
        "apikey": random_token,
        "issued": issued,
        "expires": expires,
        "not_valid_before": notvalidbefore,
        "refresh_token": hashed_refresh_token,
        "refresh_issued": issued,
        "refresh_expires": refresh_token_expiry,
        "refresh_nbf": refresh_token_nbf,
        "user_id": payload["user_id"],
        "user_role": payload["user_role"],
    })

    with engine.begin() as conn:
        conn.execute(ins)

    #
    # return the API key to the frontend
    #

    LOGGER.info("[%s] No-session API key request successful. "
                "user_id: %s, role: '%s', "
                "ip_address: %s requested a no-session API key for "
                "audience: '%s', subject: '%s', apiversion: %s. "
                "No-session API key not valid before: %s, expires on: %s. "
                "Refresh token for key expires on: %s." % (
                    payload["reqid"],
                    pii_hash(payload["user_id"], payload["pii_salt"]),
                    payload["user_role"],
                    pii_hash(payload["ip_address"], payload["pii_salt"]),
                    payload["audience"],
                    payload["subject"],
                    payload["apiversion"],
                    notvalidbefore.isoformat(),
                    expires.isoformat(),
                    refresh_token_expiry.isoformat(),
                ))

    messages = ("API key generated successfully, expires: %s." %
                expires.isoformat())

    return {
        "success": True,
        "apikey": apikey_json,
        "expires": expires.isoformat(),
        "refresh_token": refresh_token,
        "refresh_token_expires": refresh_token_expiry.isoformat(),
        "messages": ([messages]),
    }
Example #5
0
def check_user_access(
    payload: dict,
    raiseonfail: bool = False,
    override_permissions_json: str = None,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Checks for user access to a specified item based on a permissions policy.

    Parameters
    ----------

    payload : dict
        This is the input payload dict. Required items:

        - user_id: int
        - user_role: str
        - action: str
        - target_name: str
        - target_owner: int
        - target_visibility: str
        - target_sharedwith: 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

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        load and use for this request. Normally, the path to the permissions
        JSON has already been specified as a process-local variable by the main
        authnzerver start up routines. If you want to use some other permissions
        model JSON (e.g. for testing), provide that here.

        Note that we load the permissions JSON from disk every time we need to
        take a decision. This might be a bit slower, but allows for much faster
        policy changes by just changing the permissions JSON file and not having
        to restart the authnzerver.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    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
        The dict returned is of the form::

            {'success': True or False,
             'messages': list of str messages if any}

    """

    engine, meta, permjson, dbpath = get_procdb_permjson(
        override_authdb_path=override_authdb_path,
        override_permissions_json=override_permissions_json,
        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 access grant request."],
            }

    for key in {
            "user_id",
            "user_role",
            "action",
            "target_name",
            "target_owner",
            "target_visibility",
            "target_sharedwith",
    }:

        if key not in payload:
            LOGGER.error("[%s] Invalid access grant request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": ["Invalid access grant request."],
            }

    originating_userid = int(payload["user_id"])
    originating_user_role = payload["user_role"]
    target_userid = int(payload["target_owner"])
    target_sharedwith = payload["target_sharedwith"]

    try:

        # validate the access request
        access_granted = load_policy_and_check_access(
            permjson,
            userid=payload["user_id"],
            role=payload["user_role"],
            action=payload["action"],
            target_name=payload["target_name"],
            target_owner=payload["target_owner"],
            target_visibility=payload["target_visibility"],
            target_sharedwith=payload["target_sharedwith"],
        )

        users = meta.tables["users"]
        userids_to_check = [originating_userid, target_userid]

        if (target_sharedwith and target_sharedwith != ""
                and target_sharedwith.lower() != "none"):

            sharedwith_userids = target_sharedwith.split(",")
            sharedwith_userids = [int(x) for x in sharedwith_userids]
            userids_to_check.extend(sharedwith_userids)

        userids_to_check = list(set(userids_to_check))

        # check if the originating_userid is legit
        s = (select(users.c.user_id).select_from(users).where(
            users.c.user_id == originating_userid).where(
                users.c.user_role == originating_user_role).where(
                    users.c.is_active.is_(True)))
        with engine.begin() as conn:
            result = conn.execute(s)
            row = result.scalar()

        if not row or row != originating_userid:

            LOGGER.warning(
                "[%s] Access check failed. "
                "user_id: %s with role: '%s' attempted '%s' on '%s', "
                "which was owned by user_id: %s and had visibility: '%s'. " % (
                    payload["reqid"],
                    pii_hash(originating_userid, payload["pii_salt"]),
                    payload["user_role"],
                    payload["action"],
                    payload["target_name"],
                    pii_hash(target_userid, payload["pii_salt"]),
                    payload["target_visibility"],
                ))

            return {
                "success":
                False,
                "failure_reason":
                "user initiating request is not valid",
                "messages":
                ["Access request check successful. "
                 "Access granted: False."],
            }

        # now check if the rest of the user IDs make sense
        s = (select(users.c.user_id, ).select_from(users).where(
            users.c.user_id.in_(userids_to_check)).where(
                users.c.is_active.is_(True)))

        with engine.begin() as conn:
            result = conn.execute(s)
            rows = result.fetchall()

        try:

            # make sure all of the userids to check were found in the DB
            if rows and len(rows) > 0:

                users_found = list(list(zip(*rows))[0])
                if sorted(userids_to_check) == sorted(users_found):

                    LOGGER.info(
                        "[%s] Access check success: %s. "
                        "user_id: %s with role: '%s' attempted '%s' on '%s', "
                        "which was owned by user_id: %s "
                        "and had visibility: '%s'. " % (
                            payload["reqid"],
                            access_granted,
                            pii_hash(originating_userid, payload["pii_salt"]),
                            payload["user_role"],
                            payload["action"],
                            payload["target_name"],
                            pii_hash(target_userid, payload["pii_salt"]),
                            payload["target_visibility"],
                        ))

                    retdict = {
                        "success":
                        access_granted,
                        "messages": [
                            "Access request check successful. "
                            "Access granted: %s." % access_granted
                        ],
                    }

                    if not access_granted:
                        retdict["failure_reason"] = "action not permitted"

                    return retdict

                else:

                    LOGGER.warning(
                        "[%s] Access check failed. "
                        "user_id: %s with role: '%s' attempted '%s' on '%s', "
                        "which was owned by user_id: %s "
                        "and had visibility: '%s'. " % (
                            payload["reqid"],
                            pii_hash(originating_userid, payload["pii_salt"]),
                            payload["user_role"],
                            payload["action"],
                            payload["target_name"],
                            pii_hash(target_userid, payload["pii_salt"]),
                            payload["target_visibility"],
                        ))

                    return {
                        "success":
                        False,
                        "failure_reason":
                        ("users specified as owner or shared-with not found"),
                        "messages": [
                            "Access request check successful. "
                            "Access granted: False."
                        ],
                    }

            else:

                LOGGER.warning(
                    "[%s] Access check failed. "
                    "user_id: '%s' with role: '%s' attempted '%s' on '%s', "
                    "which was owned by user_id: %s "
                    "and had visibility: '%s'." % (
                        payload["reqid"],
                        pii_hash(originating_userid, payload["pii_salt"]),
                        payload["user_role"],
                        payload["action"],
                        payload["target_name"],
                        pii_hash(target_userid, payload["pii_salt"]),
                        payload["target_visibility"],
                    ))

                return {
                    "success":
                    False,
                    "failure_reason":
                    ("none of the users specified in the request were found"),
                    "messages": [
                        "Access request check successful. "
                        "Access granted: False."
                    ],
                }

        except Exception as e:

            LOGGER.error(
                "[%s] Access check ran into an exception: %r. "
                "user_id: %s with role: '%s' attempted '%s' on '%s', "
                "which was owned by user_id: '%s' and had visibility: '%s'." %
                (
                    payload["reqid"],
                    e,
                    pii_hash(originating_userid, payload["pii_salt"]),
                    payload["user_role"],
                    payload["action"],
                    payload["target_name"],
                    pii_hash(target_userid, payload["pii_salt"]),
                    payload["target_visibility"],
                ))

            if raiseonfail:
                raise

            return {
                "success": False,
                "failure_reason": "exception when checking the DB",
                "messages": ["Access request check failed."],
            }

    except Exception as e:

        if raiseonfail:
            raise

        LOGGER.error(
            "[%s] Access check ran into an exception: %r. "
            "user_id: %s with role: '%s' attempted '%s' on '%s', "
            "which was owned by user_id: %s and had visibility: '%s'." % (
                payload["reqid"],
                e,
                pii_hash(originating_userid, payload["pii_salt"]),
                payload["user_role"],
                payload["action"],
                payload["target_name"],
                pii_hash(target_userid, payload["pii_salt"]),
                payload["target_visibility"],
            ))

        return {
            "success": False,
            "failure_reason": "exception when checking the DB",
            "messages": ["Could not validate access to the requested item."],
        }
Example #6
0
def set_user_email_sent(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Sets the verify/forgot email sent flag & time for the newly created user.

    This is useful when some other way of emailing the user to verify their sign
    up or their password forgot request is used, external to authnzerver. Use
    this function to let the authnzerver know that an email has been sent so it
    knows the correct move if someone tries to sign up for an account with the
    same email address later.

    Parameters
    ----------

    payload : dict
        This is a dict with the following key:

        - email, str
        - email_type, str: one of "signup", "forgotpass"

        Finally, 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 the email address and
        email*_sent_datetime values if the sent-email notification was
        successfully set.

    """

    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 email sent notification request."],
            }

    for key in ("email", "email_type"):
        if key not in payload:

            LOGGER.error(
                "[%s] Invalid email sent notification request, missing %s." %
                (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": ["Invalid email sent notification request."],
            }

    users = meta.tables["users"]

    email_sent_datetime = datetime.utcnow()

    if payload["email_type"] == "signup":
        update_col = "emailverify_sent_datetime"
    elif payload["email_type"] == "forgotpass":
        update_col = "emailforgotpass_sent_datetime"
    else:
        LOGGER.error("[%s] Invalid email sent notification request, "
                     "incorrect email_type." % payload["reqid"])
        return {
            "success": False,
            "failure_reason":
            ("invalid request: invalid email_type requested"),
            "messages": ["Invalid email sent notification request."],
        }

    # update the table for this user
    upd = (users.update().where(users.c.email == payload["email"]).values({
        update_col:
        email_sent_datetime,
    }))
    with engine.begin() as conn:
        conn.execute(upd)
        sel = (select(
            users.c.user_id,
            users.c.is_active,
            users.c.user_role,
            users.c.email,
            users.c.emailverify_sent_datetime,
            users.c.emailforgotpass_sent_datetime,
        ).select_from(users).where((users.c.email == payload["email"])))
        result = conn.execute(sel)
        rows = result.first()

    if rows:

        LOGGER.info("[%s] Email sent notification request succeeded for "
                    "user_id: %s, email: %s, role: %s, is_active: %s." % (
                        payload["reqid"],
                        pii_hash(rows.user_id, payload["pii_salt"]),
                        pii_hash(payload["email"], payload["pii_salt"]),
                        pii_hash(rows.user_role, payload["pii_salt"]),
                        rows.is_active,
                    ))

        return {
            "success": True,
            "email": rows.email,
            "emailverify_sent_datetime": rows.emailverify_sent_datetime,
            "emailforgotpass_sent_datetime":
            (rows.emailforgotpass_sent_datetime),
            "user_id": rows.user_id,
            "is_active": rows.is_active,
            "user_role": rows.user_role,
            "messages": ["Email sent notification request succeeded."],
        }

    else:

        LOGGER.error("[%s] Email sent notification request failed for "
                     "email: %s."
                     "The database rows corresponding to "
                     "the user could not be updated." % (
                         payload["reqid"],
                         pii_hash(rows.user_id, payload["pii_salt"]),
                     ))

        return {
            "success": False,
            "failure_reason": "could not update the email sent status in DB",
            "messages": ["Email sent notification request failed."],
        }
Example #7
0
def send_signup_verification_email(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Sends an account verification email.

    Parameters
    -----------

    payload : dict
        Keys expected in this dict from a client are:

        - email_address: str, the email address to send the email to
        - session_token: str, session token of the user being sent the email
        - created_info: dict, the dict returned by ``users.auth_create_user()``
        - server_name: str, the name of the frontend server
        - server_baseurl: str, the base URL of the frontend server
        - account_verify_url: str, the URL fragment of the frontend verification
          endpoint
        - verification_token: str, a verification token generated by frontend
        - verification_expiry: int, number of seconds after which the token
          expires

        In addition, the following optional items must be provided by a wrapper
        function to set up the email server.

        - emailuser
        - emailpass
        - emailserver
        - emailport
        - emailsender

        These can be provided as part of the payload as dict keys or as
        attributes in the SimpleNamespace object passed in the config kwarg. The
        config object will be checked first, and the payload items will override
        it.

        Finally, 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 the user_id, email_address, and the
        emailverify_sent_datetime value if email was sent successfully.

    """

    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),
                "user_id":
                None,
                "email_address":
                None,
                "emailverify_sent_datetime":
                None,
                "messages": ["Invalid verify email request."],
            }

    for key in {
            "email_address",
            "session_token",
            "server_name",
            "server_baseurl",
            "account_verify_url",
            "verification_token",
            "verification_expiry",
            "created_info",
    }:

        if key not in payload:

            LOGGER.error("[%s] Invalid verify email request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "user_id":
                None,
                "email_address":
                None,
                "emailverify_sent_datetime":
                None,
                "messages": (["Invalid verify email request."]),
            }

    # now check for the SMTP server config items in the payload or in config
    if config is not None:

        emailsender = getattr(config, "emailsender", None)
        emailuser = getattr(config, "emailuser", None)
        emailpass = getattr(config, "emailpass", None)
        emailserver = getattr(config, "emailserver", None)
        emailport = getattr(config, "emailport", None)

    else:
        emailsender, emailuser, emailpass, emailserver, emailport = (
            None,
            None,
            None,
            None,
            None,
        )

    # override with payload values
    if "emailsender" in payload:
        emailsender = payload["emailsender"]
    if "emailuser" in payload:
        emailuser = payload["emailuser"]
    if "emailpass" in payload:
        emailpass = payload["emailpass"]
    if "emailserver" in payload:
        emailserver = payload["emailserver"]
    if "emailport" in payload:
        emailport = payload["emailport"]

    if emailsender is None or emailserver is None or emailport is None:

        LOGGER.error("[%s] Invalid email server settings "
                     "provided. Can't send an email." % payload["reqid"])
        return {
            "success":
            False,
            "failure_reason": ("invalid request: missing 'emailsender', "
                               "'emailserver', or 'emailport' in request"),
            "user_id":
            None,
            "email_address":
            None,
            "emailverify_sent_datetime":
            None,
            "messages": ([
                "Invalid email server settings provided. "
                "Can't send an email."
            ]),
        }

    # check if we don't need to send an email to this user
    if payload["created_info"]["send_verification"] is False:

        LOGGER.error(
            "[%s] Verify email request failed for "
            "user_id: %s, email: %s, session_token: %s."
            "Not allowed to send a verification email to this user." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("not allowed to send verification email to target user, "
             "send_verification = False"),
            "user_id":
            None,
            "email_address":
            None,
            "emailverify_sent_datetime":
            None,
            "messages":
            (["Not allowed to send an email verification request."]),
        }

    users = meta.tables["users"]

    # first, we'll verify the user was created successfully, their account is
    # currently set to inactive and their role is 'locked'. then, we'll verify
    # if the session token provided exists and get the IP address and the
    # browser identifier out of it.
    # look up the provided user
    user_sel = (select(
        users.c.user_id,
        users.c.email,
        users.c.is_active,
        users.c.user_role,
    ).select_from(users).where(
        users.c.email == payload["email_address"]).where(
            users.c.user_id == payload["created_info"]["user_id"]))

    with engine.begin() as conn:
        user_results = conn.execute(user_sel)
        user_info = user_results.first()

    if not user_info:

        LOGGER.error(
            "[%s] Verify email request failed for "
            "user_id: %s, email: %s, session_token: %s."
            "The specified user does not exist." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success": False,
            "failure_reason":
            ("target user for verification email does not exist"),
            "user_id": None,
            "email_address": None,
            "emailverify_sent_datetime": None,
            "messages": (["Invalid verify email request."]),
        }

    if user_info.is_active or user_info.user_role != "locked":

        LOGGER.error(
            "[%s] Verify email request failed for "
            "user_id: %s, email: %s, session_token: %s."
            "The specified user is already active and "
            "does not need a verification email." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("target user for verification email already active and verified"),
            "user_id":
            None,
            "email_address":
            None,
            "emailverify_sent_datetime":
            None,
            "messages":
            (["Not sending an verify email request to an existing user."]),
        }

    # check the session
    session_info = auth_session_exists(
        {
            "session_token": payload["session_token"],
            "pii_salt": payload["pii_salt"],
            "reqid": payload["reqid"],
        },
        raiseonfail=raiseonfail,
        override_authdb_path=override_authdb_path,
    )

    if not session_info["success"]:

        LOGGER.error(
            "[%s] Verify email request failed for "
            "user_id: %s, email: %s, session_token: %s."
            "The session requesting a verify email is not valid." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("invalid session for requesting a verification email"),
            "user_id":
            None,
            "email_address":
            None,
            "emailverify_sent_datetime":
            None,
            "messages": (["Invalid verify email request."]),
        }

    # get the IP address and browser ID from the session
    ip_addr = session_info["session_info"]["ip_address"]
    browser = session_info["session_info"]["user_agent"]

    # TODO: we'll use geoip to get the location of the person who initiated the
    # request.

    # get the verification token's expiry datetime
    verification_expiry_td = timedelta(seconds=payload["verification_expiry"])
    verification_expiry_dt = (datetime.utcnow() +
                              verification_expiry_td).isoformat()

    # format the verification token and wrap it to 70 chars per line because now
    # it's a bit too long for one line. we'll use a textbox on the verification
    # page to let people to paste this in
    if isinstance(payload["verification_token"], bytes):
        payload["verification_token"] = payload["verification_token"].decode(
            "utf-8")

    formatted_verification_token = "\n".join(
        textwrap.wrap(payload["verification_token"]))

    # generate the email message
    msgtext = SIGNUP_VERIFICATION_EMAIL_TEMPLATE.format(
        server_baseurl=payload["server_baseurl"],
        server_name=payload["server_name"],
        account_verify_url=payload["account_verify_url"],
        verification_code=formatted_verification_token,
        verification_expiry="%s (UTC time)" % verification_expiry_dt,
        browser_identifier=browser.replace("_", "."),
        ip_address=ip_addr,
        user_email=payload["email_address"],
    )
    recipients = [user_info.email]
    subject = SIGNUP_VERIFICATION_EMAIL_SUBJECT.format(
        server_name=payload["server_name"])

    # send the email
    email_sent = send_email(
        emailsender,
        subject,
        msgtext,
        recipients,
        emailserver,
        emailuser,
        emailpass,
        payload["pii_salt"],
        port=emailport,
    )

    if email_sent:

        emailverify_sent_datetime = datetime.utcnow()

        # finally, we'll update the users table with the actual
        # emailverify_sent_datetime if sending succeeded.
        upd = (users.update().where(
            users.c.user_id == payload["created_info"]["user_id"]).where(
                users.c.is_active.is_(False)).where(
                    users.c.email == payload["created_info"]
                    ["user_email"]).values({
                        "emailverify_sent_datetime":
                        emailverify_sent_datetime,
                    }))
        with engine.begin() as conn:
            conn.execute(upd)

        LOGGER.info(
            "[%s] Verify email request succeeded for "
            "user_id: %s, email: %s, session_token: %s. "
            "Email sent on: %s UTC." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
                emailverify_sent_datetime.isoformat(),
            ))

        return {
            "success": True,
            "user_id": user_info.user_id,
            "email_address": user_info.email,
            "emailverify_sent_datetime": emailverify_sent_datetime,
            "messages": (["Verify email sent successfully."]),
        }

    else:

        LOGGER.error(
            "[%s] Verify email request failed for "
            "user_id: %s, email: %s, session_token: %s."
            "The email server could not send the email "
            "to the specified address." % (
                payload["reqid"],
                pii_hash(payload["created_info"]["user_id"],
                         payload["pii_salt"]),
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason": ("verification email could not be sent "
                               "because of an email server problem"),
            "user_id":
            None,
            "email_address":
            None,
            "emailverify_sent_datetime":
            None,
            "messages":
            (["Could not send email for the verify email request."]),
        }
Example #8
0
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."],
        }
Example #9
0
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."],
        }
Example #10
0
def internal_edit_session(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Handles editing the *extra_info_json* field for an existing user session.

    Meant for use internally in a frontend server.

    Parameters
    ----------

    payload : dict
        The input payload dict. Required items:

        - target_session_token: int, the session to edit
        - update_dict: dict, the changes to make to the *extra_info_json* column
          of the sessions table for the target session token.

        The *extra_info_json* field in the database will be updated with the
        info in *update_dict*. To delete an item from *extra_info_json*, pass in
        the special value of "__delete__" in *update_dict* for that item.

        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

    raiseonfail : bool
        If True, and something goes wrong, this will raise an Exception instead
        of returning normally with a failure condition.

    override_authdb_path : str or None
        The SQLAlchemy database URL to use if not using the default auth DB.

    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 the new session information.

    """

    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 {
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "success":
                False,
                "session_token":
                None,
                "expires":
                None,
                "messages": ["Invalid session edit request."],
            }

    for key in ("target_session_token", "update_dict"):

        if key not in payload:

            LOGGER.error("[%s] Invalid session edit request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "session_info":
                None,
                "messages": [
                    "Invalid session edit request: "
                    "missing or invalid parameters."
                ],
            }

    target_session_token = payload["target_session_token"]
    update_dict = payload["update_dict"]
    if update_dict is None or len(update_dict) == 0:
        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing 'update_dict' in request"),
            "session_info":
            None,
            "messages": [
                "Invalid session edit request: "
                "missing or invalid parameters."
            ],
        }

    try:

        with engine.begin() as conn:

            sessions = meta.tables["sessions"]

            sel = (select(
                sessions.c.session_token,
                sessions.c.extra_info_json).select_from(sessions).where(
                    sessions.c.session_token == target_session_token).where(
                        sessions.c.expires > datetime.utcnow()))
            result = conn.execute(sel)
            sessiontoken_extrainfo = result.first()

        if not sessiontoken_extrainfo or len(sessiontoken_extrainfo) == 0:
            return {
                "success": False,
                "failure_reason": "no such session",
                "session_info": None,
                "messages": ["Session extra_info update failed."],
            }

        #
        # update the extra_info_json dict
        #
        session_extra_info = sessiontoken_extrainfo.extra_info_json
        if not session_extra_info:
            session_extra_info = {}

        for key, val in update_dict.items():
            if val == "__delete__" and key in session_extra_info:
                del session_extra_info[key]
            else:
                session_extra_info[key] = val

        # write it back to the session column
        # get back the new version
        with engine.begin() as conn:

            upd = (sessions.update().where(
                sessions.c.session_token == target_session_token).values(
                    {"extra_info_json": session_extra_info}))
            conn.execute(upd)

            s = (select(
                sessions.c.user_id,
                sessions.c.session_token,
                sessions.c.ip_address,
                sessions.c.user_agent,
                sessions.c.created,
                sessions.c.expires,
                sessions.c.extra_info_json,
            ).select_from(sessions).where(
                (sessions.c.session_token == target_session_token)
                & (sessions.c.expires > datetime.utcnow())))
            result = conn.execute(s)
            row = result.first()

        try:

            serialized_result = dict(row._mapping)
            LOGGER.info("[%s] Session info updated for "
                        "user_id: %s with IP address: %s, "
                        "user agent: %s, session_token: %s. "
                        "Session expires on: %s" % (
                            payload["reqid"],
                            pii_hash(serialized_result["user_id"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["ip_address"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["user_agent"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["session_token"],
                                     payload["pii_salt"]),
                            serialized_result["expires"],
                        ))

            return {
                "success": True,
                "session_info": serialized_result,
                "messages": ["Session extra_info update successful."],
            }

        except Exception as e:

            LOGGER.error(
                "[%s] Session info update failed for session token: %s. "
                "Exception was: %r." % (
                    payload["reqid"],
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                    e,
                ))

            return {
                "success":
                False,
                "failure_reason":
                ("session requested for update doesn't exist or expired"),
                "session_info":
                None,
                "messages": ["Session extra_info update failed."],
            }

    except Exception as e:

        LOGGER.error(
            "[%s] Session edit failed for user_id: %s. "
            "Exception was: %r." % (
                payload["reqid"],
                pii_hash(payload["target_userid"], payload["pii_salt"]),
                e,
            ))

        return {
            "success": False,
            "failure_reason": "DB error when updating session info",
            "session_info": None,
            "messages": ["Session info update failed."],
        }
Example #11
0
def auth_password_check(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """This runs a password check given a session token and password.

    Used to gate high-security areas or operations that require re-verification
    of the password for a user's existing session.

    Parameters
    ----------

    payload : dict
        This is a dict containing the following items:

        - session_token
        - password

        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
        The SQLAlchemy database URL to use if not using the default auth DB.

    raiseonfail : bool
        If True, and something goes wrong, this will raise an Exception instead
        of returning normally with a failure condition.

    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 the result of the password verification
        check.

    """

    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),
                "user_id":
                None,
                "messages": ["Invalid password check request."],
            }

    # check broken request
    request_ok = True

    for item in ("password", "session_token"):
        if item not in payload:
            request_ok = False
            break

    # this checks if the database connection is live
    users = meta.tables["users"]

    #
    # check if the request is OK
    #

    # if it isn't, then hash the dummy user's password twice
    if not request_ok:

        # dummy session request
        auth_session_exists(
            {
                "session_token": "nope",
                "reqid": payload["reqid"],
                "pii_salt": payload["pii_salt"],
            },
            raiseonfail=raiseonfail,
            override_authdb_path=override_authdb_path,
        )

        # get the dummy user's password from the DB on an outright failure -
        # run this twice to match the number of verifications for a normal
        # successful user
        dummy_sel = (select(
            users.c.password).select_from(users).where(users.c.user_id == 3))
        with engine.begin() as conn:
            for _ in range(2):
                dummy_results = conn.execute(dummy_sel)
                dummy_password = dummy_results.scalar()
                try:
                    pass_hasher.verify(dummy_password, "nope")
                except Exception:
                    pass

        LOGGER.error(
            "[%s] Password check failed for session_token: %s. "
            "Missing request items." % (
                payload["reqid"],
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing either 'password' or 'session_token'"),
            "user_id":
            None,
            "messages": ["Invalid password verification request."],
        }

    # otherwise, now we'll check if the session exists
    else:

        session_info = auth_session_exists(
            {
                "session_token": payload["session_token"],
                "reqid": payload["reqid"],
                "pii_salt": payload["pii_salt"],
            },
            raiseonfail=raiseonfail,
            override_authdb_path=override_authdb_path,
        )

        # if it doesn't, hash the dummy password twice
        if not session_info["success"]:

            # get the dummy user's password from the DB on an outright failure
            # run this twice to match the number of verifications for a normal
            # successful user
            dummy_sel = (select(users.c.password).select_from(users).where(
                users.c.user_id == 3))
            with engine.begin() as conn:
                for _ in range(2):
                    dummy_results = conn.execute(dummy_sel)
                    dummy_password = dummy_results.scalar()
                    try:
                        pass_hasher.verify(dummy_password, "nope")
                    except Exception:
                        pass

            LOGGER.error(
                "[%s] Password check failed for session_token: %s. "
                "The session token provided does not exist." % (
                    payload["reqid"],
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                ))

            return {
                "success": False,
                "failure_reason": "session does not exist",
                "user_id": None,
                "messages": ["No session token provided."],
            }

        # if the session token does exist, we'll proceed to checking the
        # password for the provided email
        else:

            with engine.begin() as conn:
                # always get the dummy user's password from the DB
                dummy_sel = (select(users.c.password).select_from(users).where(
                    users.c.user_id == 3))
                dummy_results = conn.execute(dummy_sel)
                dummy_password = dummy_results.scalar()
                try:
                    pass_hasher.verify(dummy_password, "nope")
                except Exception:
                    pass

                # look up the actual provided user
                user_sel = (select(
                    users.c.user_id,
                    users.c.password,
                    users.c.is_active,
                    users.c.user_role,
                ).select_from(users).where(
                    users.c.user_id == session_info["session_info"]["user_id"])
                            )
                user_results = conn.execute(user_sel)
                user_info = user_results.first()

            if user_info:

                try:

                    pass_ok = pass_hasher.verify(
                        user_info.password,
                        payload["password"][:256],
                    )

                except Exception as e:

                    LOGGER.error(
                        "[%s] Password check failed for session_token: %s. "
                        "The password provided does not match the one on "
                        "record for user_id: %s. Exception was: %r" % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                            e,
                        ))
                    pass_ok = False

            else:

                try:
                    pass_hasher.verify(dummy_password, "nope")
                except Exception:
                    pass

                pass_ok = False

            if not pass_ok:

                return {
                    "success":
                    False,
                    "failure_reason":
                    ("user does not exist or password doesn't match"),
                    "user_id":
                    None,
                    "messages": [
                        "Sorry, that user ID and "
                        "password combination didn't work."
                    ],
                }

            # if password verification succeeeded, check if the user can
            # actually log in (i.e. their account is not locked or is not
            # inactive)
            else:

                # if the user account is active and unlocked, proceed.
                # the frontend will take this user_id and ask for a new session
                # token with it.
                if user_info.is_active and user_info.user_role != "locked":

                    LOGGER.info(
                        "[%s] Password check successful for "
                        "session_token: %s. "
                        "Matched user with user_id: %s. " % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                        ))

                    return {
                        "success": True,
                        "user_id": user_info.user_id,
                        "user_role": user_info.user_role,
                        "messages": ["Verification successful."],
                    }

                # if the user account is locked, return a failure
                else:

                    LOGGER.error(
                        "[%s] Password check failed for session_token: %s. "
                        "Matched user with user_id: %s is not active "
                        "or is locked." % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                        ))

                    return {
                        "success":
                        False,
                        "failure_reason":
                        "user exists but is inactive",
                        "user_id":
                        user_info.user_id,
                        "messages": [
                            "Sorry, that user ID and "
                            "password combination didn't work."
                        ],
                    }
Example #12
0
def change_user_password(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    min_pass_length: int = 12,
    max_unsafe_similarity: int = 33,
    config: SimpleNamespace = None,
) -> dict:
    """Changes the user's password.

    Parameters
    ----------

    payload : dict
        This is a dict with the following required keys:

        - user_id: int
        - session_token: str
        - full_name: str
        - email: str
        - current_password: str
        - new_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.

    min_pass_length : int
        The minimum required character length of the password. The value
        provided in this kwarg will be overriden by the ``passpolicy`` attribute
        in the config object if that is passed in as well.

    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. The value
        provided in this kwarg will be overriden by the ``passpolicy`` attribute
        in the config object if that is passed in as well.

    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 email as keys if successful.

    Notes
    -----

    This logs out the user from all of their other sessions.

    """

    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),
                "user_id":
                None,
                "email":
                None,
                "messages": ["Invalid password change request."],
            }

    for key in {
            "user_id",
            "session_token",
            "full_name",
            "email",
            "current_password",
            "new_password",
    }:

        if key not in payload:

            LOGGER.error("[%s] Invalid password change 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 password change request. "
                 "Some args are missing."],
            }

    users = meta.tables["users"]

    # get the current password
    sel = (select(users.c.password, ).select_from(users).where(
        users.c.user_id == payload["user_id"]).where(
            users.c.email == payload["email"]).where(
                users.c.is_active.is_(True)))

    with engine.begin() as conn:
        result = conn.execute(sel)
        rows = result.first()

    if not rows or len(rows) == 0:

        LOGGER.error("[%s] Password change request failed for "
                     "user_id: %s, email: %s. "
                     "The user was not found in the DB or is inactive." % (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        return {
            "success":
            False,
            "failure_reason":
            "user does not exist",
            "user_id":
            payload["user_id"],
            "email":
            payload["email"],
            "messages":
            ["Your current password did not match the stored password."],
        }

    #
    # proceed with hashing
    #
    current_password = payload["current_password"][:256]
    new_password = payload["new_password"][:256]

    try:
        pass_check = pass_hasher.verify(rows.password, current_password)
    except Exception:
        pass_check = False

    if not pass_check:

        LOGGER.error("[%s] Password change request failed for "
                     "user_id: %s, email: %s. "
                     "The input password did not match the stored password." %
                     (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        return {
            "success":
            False,
            "failure_reason":
            "user password does not match",
            "user_id":
            payload["user_id"],
            "email":
            payload["email"],
            "messages":
            ["Your current password did not match the stored password."],
        }

    # check if the new hashed password is the same as the old hashed password,
    # meaning that the new password is just the old one
    try:
        same_check = pass_hasher.verify(rows.password, new_password)
    except Exception:
        same_check = False

    if same_check:

        LOGGER.error("[%s] Password change request failed for "
                     "user_id: %s, email: %s. "
                     "The new password was the same as the current password." %
                     (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        return {
            "success":
            False,
            "failure_reason":
            "password did not change",
            "user_id":
            payload["user_id"],
            "email":
            payload["email"],
            "messages":
            ["Your new password cannot be the same as your old password."],
        }

    # hash the user's password
    hashed_password = pass_hasher.hash(new_password)

    # validate the input password to see if it's OK
    # do this here to make sure the password hash completes at least once
    # verify the new password is OK
    passok, messages = validate_input_password(
        payload["full_name"],
        payload["email"],
        new_password,
        payload["pii_salt"],
        payload["reqid"],
        min_pass_length=min_pass_length,
        max_unsafe_similarity=max_unsafe_similarity,
        config=config,
    )

    if passok:

        # update the table for this user
        upd = (users.update().where(
            users.c.user_id == payload["user_id"]).where(
                users.c.is_active.is_(True)).where(
                    users.c.email == payload["email"]).values(
                        {"password": hashed_password}))

        with engine.begin() as conn:
            conn.execute(upd)

            sel = (select(users.c.password, ).select_from(users).where(
                (users.c.user_id == payload["user_id"])))
            result = conn.execute(sel)
            rows = result.first()

        if rows and rows.password == hashed_password:
            messages.append("Password changed successfully.")

            LOGGER.info("[%s] Password change request succeeded for "
                        "user_id: %s, email: %s." % (
                            payload["reqid"],
                            pii_hash(payload["user_id"], payload["pii_salt"]),
                            pii_hash(payload["email"], payload["pii_salt"]),
                        ))

            # delete all of this user's other sessions
            auth_delete_sessions_userid(
                {
                    "session_token": payload["session_token"],
                    "user_id": payload["user_id"],
                    "keep_current_session": True,
                    "reqid": payload["reqid"],
                    "pii_salt": payload["pii_salt"],
                },
                override_authdb_path=override_authdb_path,
                raiseonfail=raiseonfail,
            )

            return {
                "success":
                True,
                "user_id":
                payload["user_id"],
                "email":
                payload["email"],
                "messages": (messages + [
                    "For security purposes, you have been "
                    "logged out of all other sessions."
                ]),
            }

        else:

            messages.append("Password could not be changed.")

            LOGGER.error("[%s] Password change request failed for "
                         "user_id: %s, email: %s. "
                         "The user row could not be updated in the DB." % (
                             payload["reqid"],
                             pii_hash(payload["user_id"], payload["pii_salt"]),
                             pii_hash(payload["email"], payload["pii_salt"]),
                         ))

            return {
                "success": False,
                "failure_reason": "DB error when updating password",
                "user_id": payload["user_id"],
                "email": payload["email"],
                "messages": messages,
            }

    else:

        LOGGER.error("[%s] Password change request failed for "
                     "user_id: %s, email: %s. "
                     "The new password entered is insecure." % (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        messages.append("The new password you entered is insecure. "
                        "It must be at least 12 characters long and "
                        "be sufficiently complex.")
        return {
            "success": False,
            "failure_reason": "new password is insecure",
            "user_id": payload["user_id"],
            "email": payload["email"],
            "messages": messages,
        }
Example #13
0
def check_user_limit(
    payload: dict,
    raiseonfail: bool = False,
    override_permissions_json: str = None,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Applies a specified limit to an item based on a permissions policy.

    Parameters
    ----------

    payload : dict
        This is the input payload dict. Required items:

        - user_id: int
        - user_role: str
        - limit_name: str
        - value_to_check: any

        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

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        load and use for this request. Normally, the path to the permissions
        JSON has already been specified as a process-local variable by the main
        authnzerver start up routines. If you want to use some other permissions
        model JSON (e.g. for testing), provide that here.

        Note that we load the permissions JSON from disk every time we need to
        take a decision. This might be a bit slower, but allows for much faster
        policy changes by just changing the permissions JSON file and not having
        to restart the authnzerver.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    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
        The dict returned is of the form::

            {'success': True or False,
             'messages': list of str messages if any}

    """

    engine, meta, permjson, dbpath = get_procdb_permjson(
        override_authdb_path=override_authdb_path,
        override_permissions_json=override_permissions_json,
        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' from request" % key),
                "messages": ["Invalid access grant request."],
            }

    for key in ("user_id", "user_role", "limit_name", "value_to_check"):

        if key not in payload:
            LOGGER.error("[%s] Invalid limit check request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' from request" % key),
                "messages": ["Invalid limit check request."],
            }

    originating_userid = int(payload["user_id"])
    originating_user_role = str(payload["user_role"])

    try:

        # load the permissions JSON
        limit_checked = load_policy_and_check_limits(
            permjson,
            payload["user_role"],
            payload["limit_name"],
            payload["value_to_check"],
        )

        # make sure the incoming user ID and role actually exist in the database
        users = meta.tables["users"]

        s = (select(users.c.user_id).select_from(users).where(
            users.c.user_id == originating_userid).where(
                users.c.user_role == originating_user_role).where(
                    users.c.is_active.is_(True)))

        with engine.begin() as conn:
            result = conn.execute(s)
            rows = result.fetchall()

        try:

            if rows and len(rows) > 0:

                LOGGER.info(
                    "[%s] Limit check success: %s. "
                    "user_id: %s with role: '%s', limit name: '%s', "
                    "value checked against limit was: %s." % (
                        payload["reqid"],
                        limit_checked,
                        pii_hash(originating_userid, payload["pii_salt"]),
                        payload["user_role"],
                        payload["limit_name"],
                        payload["value_to_check"],
                    ))

                retdict = {
                    "success":
                    limit_checked,
                    "messages": [
                        "Limit check successful. "
                        "Limit check passed: %s." % limit_checked
                    ],
                }

                if not limit_checked:
                    retdict["failure_reason"] = "user is over limit"
                return retdict

            else:

                LOGGER.warning(
                    "[%s] Limit check failed. "
                    "Possibly unknown user_id: %s with "
                    "role: '%s', limit name: '%s', "
                    "value checked against limit was: %s." % (
                        payload["reqid"],
                        pii_hash(originating_userid, payload["pii_salt"]),
                        payload["user_role"],
                        payload["limit_name"],
                        payload["value_to_check"],
                    ))

                return {
                    "success":
                    False,
                    "failure_reason":
                    "user attempting access not found",
                    "messages":
                    ["Limit check successful. "
                     "Limit check passed: False."],
                }

        except Exception as e:

            if raiseonfail:
                raise

            LOGGER.error("[%s] Limit check ran into an exception: %r. "
                         "Provided user_id: %s with "
                         "role: '%s', limit name: '%s', "
                         "value checked against limit was: %s." % (
                             payload["reqid"],
                             e,
                             pii_hash(originating_userid, payload["pii_salt"]),
                             payload["user_role"],
                             payload["limit_name"],
                             payload["value_to_check"],
                         ))

            return {
                "success": False,
                "failure_reason": "exception when checking the DB",
                "messages": ["Limit check failed."],
            }

    except Exception as e:

        if raiseonfail:
            raise

        LOGGER.error("[%s] Limit check ran into an exception: %r. "
                     "Provided user_id: %s with "
                     "role: '%s', limit name: '%s', "
                     "value checked against limit was: %s." % (
                         payload["reqid"],
                         e,
                         pii_hash(originating_userid, payload["pii_salt"]),
                         payload["user_role"],
                         payload["limit_name"],
                         payload["value_to_check"],
                     ))

        return {
            "success":
            False,
            "failure_reason":
            "exception when checking the DB",
            "messages":
            ["Could not validate limit "
             "rule for the requested item."],
        }
Example #14
0
def revoke_all_apikeys(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    override_permissions_json: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Revokes an API key.

    This does not require a session, but does require a current valid and
    unexpired API key to revoke all API keys belonging to the specified user.

    Parameters
    ----------

    payload : dict
        This dict contains the following keys:

        - apikey_dict: the decrypted and verified API key info dict from the
          frontend.

        - user_id: the user ID of the person revoking this key. Only superusers
          or staff can revoke an API key that doesn't belong to them.

        - user_role: the user ID of the person revoking this key. Only
          superusers or staff can revoke an API key that doesn't belong to them.

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        use. This is used to check if the user_id is allowed to actually revoke
        ("delete") an API key.

    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
        The dict returned is of the form::

            {'success': True if API key was revoked and False otherwise,
             'messages': list of str messages if any}

    """

    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 API key revocation request."],
            }

    for key in ("apikey_dict", "user_id", "user_role"):
        if "apikey_dict" not in payload:

            LOGGER.error(
                "[%s] Invalid API key revocation request, missing %s." %
                (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": ["Some required keys are missing from payload."],
            }

    apikey_dict = payload["apikey_dict"]
    user_id = payload["user_id"]
    user_role = payload["user_role"]

    # check if the user is allowed to revoke API keys
    apikey_revocation_allowed = check_user_access(
        {
            "user_id": user_id,
            "user_role": user_role,
            "action": "delete",
            "target_name": "apikey",
            "target_owner": apikey_dict["uid"],
            "target_visibility": "private",
            "target_sharedwith": None,
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_permissions_json=override_permissions_json,
        override_authdb_path=override_authdb_path,
    )

    if not apikey_revocation_allowed["success"]:

        LOGGER.error("[%s] Invalid API key revocation request. "
                     "from user_id: %s, role: %s. API keys are "
                     "not revocable by this user." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("originating user is not allowed to delete API keys"),
            "messages": [
                "All API keys revocation failed. "
                "You are not allowed to delete API keys."
            ],
        }

    #
    # verify the presented API key
    #
    apikey_verification = verify_apikey(
        {
            "apikey_dict": apikey_dict,
            "user_id": user_id,
            "user_role": user_role,
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_permissions_json=override_permissions_json,
        override_authdb_path=override_authdb_path,
    )

    if not apikey_verification["success"]:

        LOGGER.error("[%s] Invalid API key revocation request. "
                     "from user_id: %s, role: %s. The presented API key is "
                     "invalid and can't be used for revoking all API keys." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("provided API key is invalid, "
             "revoke-all operation requires a valid key to start with"),
            "messages": [
                "All API keys revocation failed. "
                "The API key presented is invalid."
            ],
        }

    #
    # everything checks out so go ahead and delete the API key
    #

    apikeys = meta.tables["apikeys_nosession"]

    # delete all the API keys belonging to this user ID
    delete = (apikeys.delete().where(
        apikeys.c.user_id == apikey_dict["uid"]).where(
            apikeys.c.user_role == apikey_dict["rol"]))
    with engine.begin() as conn:
        result = conn.execute(delete)
        success = result.rowcount > 0

    LOGGER.info("[%s] All API keys revocation request processed. "
                "User_id: %s, role: %s, success: %s." % (
                    payload["reqid"],
                    pii_hash(user_id, payload["pii_salt"]),
                    pii_hash(user_role, payload["pii_salt"]),
                    success,
                ))

    return {
        "success": True,
        "revoked_keys": result.rowcount,
        "messages": ["All API keys revocation processed."],
    }
Example #15
0
def auth_session_exists(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """
    Checks if the provided session token exists.

    Parameters
    ----------

    payload : dict
        This is a dict, with the following keys required:

        - session_token: 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 all of the session info if it exists and has
         not expired.

    """

    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),
                "session_info":
                None,
                "messages": ["Invalid session info request."],
            }

    if "session_token" not in payload:
        LOGGER.error(
            "[%s] Invalid session info request, missing session_token." %
            payload["reqid"])

        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing 'session_token' in request"),
            "session_info":
            None,
            "messages": ["No session token provided."],
        }

    session_token = payload["session_token"]

    try:

        with engine.begin() as conn:

            sessions = meta.tables["sessions"]
            users = meta.tables["users"]
            s = (select(
                users.c.user_id,
                users.c.system_id,
                users.c.full_name,
                users.c.email,
                users.c.extra_info,
                users.c.email_verified,
                users.c.emailverify_sent_datetime,
                users.c.is_active,
                users.c.last_login_try,
                users.c.last_login_success,
                users.c.created_on,
                users.c.user_role,
                sessions.c.session_token,
                sessions.c.ip_address,
                sessions.c.user_agent,
                sessions.c.created,
                sessions.c.expires,
                sessions.c.extra_info_json,
            ).select_from(users.join(sessions)).where(
                (sessions.c.session_token == session_token)
                & (sessions.c.expires > datetime.utcnow())))
            result = conn.execute(s)
            rows = result.first()

        try:

            serialized_result = dict(rows._mapping)

            LOGGER.info("[%s] Session info request successful for "
                        "user_id: %s with IP address: %s, "
                        "user agent: %s, session_token: %s. "
                        "Session expires on: %s" % (
                            payload["reqid"],
                            pii_hash(serialized_result["user_id"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["ip_address"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["user_agent"],
                                     payload["pii_salt"]),
                            pii_hash(serialized_result["session_token"],
                                     payload["pii_salt"]),
                            serialized_result["expires"],
                        ))

            return {
                "success": True,
                "session_info": serialized_result,
                "messages": ["Session look up successful."],
            }

        except Exception as e:

            LOGGER.error(
                "[%s] Session info lookup failed for session token: %s. "
                "Exception was: %r." % (
                    payload["reqid"],
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                    e,
                ))

            return {
                "success": False,
                "failure_reason": "session does not exist or expired",
                "session_info": None,
                "messages": ["Session look up failed."],
            }

    except Exception as e:

        LOGGER.error(
            "[%s] Session info lookup failed for session token: %s. "
            "Exception was: %r." % (
                payload["reqid"],
                pii_hash(payload["session_token"], payload["pii_salt"]),
                e,
            ))

        return {
            "success": False,
            "failure_reason": "DB error when retrieving session info",
            "session_info": None,
            "messages": ["Session look up failed."],
        }
Example #16
0
def refresh_apikey(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    override_permissions_json: str = None,
    config: SimpleNamespace = None,
):
    """Refreshes a no-session API key.

    Requires a refresh token.

    Parameters
    ----------

    payload : dict
        This dict contains the following keys:

        - apikey_dict: the decrypted and verified API key info dict from the
          frontend.

        - user_id: the user ID of the person revoking this key. Only superusers
          or staff can revoke an API key that doesn't belong to them.

        - user_role: the user ID of the person revoking this key. Only
          superusers or staff can revoke an API key that doesn't belong to them.

        - refresh_token: the refresh token needed to refresh the API key

        - ip_address: the current IP address of the user

        - expires_seconds: int, the number of seconds after which the API key
          expires

        - not_valid_before: float or int, the amount of seconds after utcnow()
          when the API key becomes valid

        - refresh_expires: int, the number of seconds after which the API key's
          refresh token expires

        - refresh_nbf: float or int, the amount of seconds after utcnow()
          after which the refresh token becomes valid

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    override_permissions_json : str or None
        If given as a str, is the alternative path to the permissions JSON to
        use. This is used to check if the user_id is allowed to actually refresh
        ("delete" then "create") an API key.

    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
        The dict returned is of the form::

            {'success': True if API key was revoked and False otherwise,
             'messages': list of str messages if any}

    """

    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 API key revocation request."],
            }

    for key in {
            "apikey_dict",
            "user_id",
            "user_role",
            "refresh_token",
            "ip_address",
            "expires_seconds",
            "not_valid_before",
            "refresh_expires",
            "refresh_nbf",
    }:

        if key not in payload:

            LOGGER.error(
                "[%s] Invalid no-session API key refresh request, missing %s."
                % (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": ["Some required keys are missing from payload."],
            }

    apikey_dict = payload["apikey_dict"]
    user_id = payload["user_id"]
    user_role = payload["user_role"]
    refresh_token = payload["refresh_token"]

    #
    # go ahead and try to refresh the API key
    #

    apikeys = meta.tables["apikeys_nosession"]
    dt_utcnow = datetime.utcnow()

    # check the hashed refresh token against the stored refresh token
    refresh_token_sel = (select(
        apikeys.c.refresh_token).select_from(apikeys).where(
            apikeys.c.apikey == apikey_dict["tkn"]).where(
                apikeys.c.user_id == apikey_dict["uid"]).where(
                    apikeys.c.user_role == apikey_dict["rol"]).where(
                        apikeys.c.refresh_expires > dt_utcnow).where(
                            apikeys.c.refresh_nbf < dt_utcnow).where(
                                apikeys.c.refresh_issued < dt_utcnow))
    with engine.begin() as conn:
        result = conn.execute(refresh_token_sel)
        stored_refresh_token_hash = result.scalar()

    if stored_refresh_token_hash is None:

        LOGGER.error(
            "[%s] Invalid no-session API key refresh request. "
            "from user_id: %s, role: '%s'. "
            "The API key presented does not have a valid refresh token." % (
                payload["reqid"],
                pii_hash(user_id, payload["pii_salt"]),
                pii_hash(user_role, payload["pii_salt"]),
            ))
        return {
            "success":
            False,
            "failure_reason":
            ("provided API key has no stored refresh token, probably invalid"),
            "messages": [
                "API key refresh failed. "
                "The API key presented does not have a valid refresh token. "
                "You may need to login again to generate a new API key."
            ],
        }

    try:
        token_hasher.verify(stored_refresh_token_hash, refresh_token)

    except Exception:
        LOGGER.error("[%s] Invalid no-session API key refresh request. "
                     "from user_id: %s, role: '%s'. "
                     "The API key presented did not pass "
                     "refresh-token hash verification." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("provided refresh token does not match stored refresh token"),
            "messages": [
                "API key refresh failed. "
                "The API key presented does not have a valid refresh token. "
                "You may need to login again to generate a new API key."
            ],
        }

    #
    # now that the refresh token has been verified, delete the old API key and
    # then generate a new API key
    #
    revoke_try = revoke_apikey(
        {
            "apikey_dict": apikey_dict,
            "user_id": user_id,
            "user_role": user_role,
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_authdb_path=override_authdb_path,
        override_permissions_json=override_permissions_json,
    )

    if not revoke_try["success"]:

        LOGGER.error("[%s] Invalid no-session API key refresh request. "
                     "from user_id: %s, role: '%s'. "
                     "Could not delete the old API key." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("user from provided API key not allowed to revoke old key"),
            "messages": [
                "API key refresh failed. "
                "The API key presented does not have a valid refresh token."
                "You may need to login again to generate a new API key."
            ],
        }

    new_apikey_try = issue_apikey(
        {
            "issuer": apikey_dict["iss"],
            "apiversion": apikey_dict["ver"],
            "user_id": user_id,
            "user_role": user_role,
            "audience": apikey_dict["aud"],
            "subject": apikey_dict["sub"],
            "ip_address": payload["ip_address"],
            "expires_seconds": payload["expires_seconds"],
            "not_valid_before": payload["not_valid_before"],
            "refresh_expires": payload["refresh_expires"],
            "refresh_nbf": payload["refresh_nbf"],
            "reqid": payload["reqid"],
            "pii_salt": payload["pii_salt"],
        },
        raiseonfail=raiseonfail,
        override_authdb_path=override_authdb_path,
        override_permissions_json=override_permissions_json,
    )

    if not new_apikey_try["success"]:

        LOGGER.error("[%s] Invalid no-session API key refresh request. "
                     "from user_id: %s, role: '%s'. "
                     "Could not generate a new API key." % (
                         payload["reqid"],
                         pii_hash(user_id, payload["pii_salt"]),
                         pii_hash(user_role, payload["pii_salt"]),
                     ))
        return {
            "success":
            False,
            "failure_reason":
            ("user from provided API key not allowed to issue new key"),
            "messages": [
                "API key refresh failed. "
                "The API key presented does not have a valid refresh token."
                "You may need to login again to generate a new API key."
            ],
        }

    #
    # otherwise, everything is ok, return the new API key
    #

    LOGGER.info("[%s] API key refresh request succeeded. "
                "User_id: %s, role: '%s'." % (
                    payload["reqid"],
                    pii_hash(user_id, payload["pii_salt"]),
                    pii_hash(user_role, payload["pii_salt"]),
                ))

    return new_apikey_try
Example #17
0
def auth_session_new(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """Generates a new session token.

    Parameters
    ----------

    payload : dict
        This is the input payload dict. Required items:

        - ip_address: str
        - user_agent: str
        - user_id: int or None (None indicates an anonymous user)
        - expires: datetime object or date string in ISO format
        - extra_info_json: dict or None

        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
        The dict returned is of the form::

        {'success: True or False,
         'session_token': str session token 32 bytes long in base64 format,
         'expires': str date in ISO format,
         'messages': list of str messages to pass on to the user if any}

    """

    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),
                "session_token":
                None,
                "expires":
                None,
                "messages": ["Invalid session initiation request."],
            }

    # fail immediately if the required payload keys are not present
    for key in {
            "ip_address",
            "user_agent",
            "user_id",
            "expires",
            "extra_info_json",
    }:

        if key not in payload:

            LOGGER.error(
                "[%s] Invalid session initiation request, missing %s." %
                (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "session_token":
                None,
                "expires":
                None,
                "messages": [
                    "Invalid session initiation request. "
                    "Missing some parameters."
                ],
            }

    try:

        validated_ip = str(ipaddress.ip_address(payload["ip_address"]))
        payload["ip_address"] = validated_ip

        # set the userid to anonuser@localhost if no user is provided
        if not payload["user_id"]:
            payload["user_id"] = 2

        # check if the payload expires key is a string and not a datetime.time
        # and reform it to a datetime if necessary
        if isinstance(payload["expires"], str):

            # this is assuming UTC
            payload["expires"] = datetime.strptime(
                payload["expires"].replace("Z", ""), "%Y-%m-%dT%H:%M:%S.%f")

        # generate a session token
        session_token = secrets.token_urlsafe(32)

        payload["session_token"] = session_token
        payload["created"] = datetime.utcnow()

        with engine.begin() as conn:
            sessions = meta.tables["sessions"]
            ins = insert(sessions).values({
                "session_token":
                session_token,
                "ip_address":
                payload["ip_address"],
                "user_agent":
                payload["user_agent"],
                "user_id":
                payload["user_id"],
                "expires":
                payload["expires"],
                "extra_info_json":
                payload["extra_info_json"],
            })
            conn.execute(ins)

        LOGGER.info(
            "[%s] New session initiated for "
            "user_id: %s with IP address: %s, user agent: %s. Expires on: %s" %
            (
                payload["reqid"],
                pii_hash(payload["user_id"], payload["pii_salt"]),
                pii_hash(payload["ip_address"], payload["pii_salt"]),
                pii_hash(payload["user_agent"], payload["pii_salt"]),
                payload["expires"],
            ))

        return {
            "success":
            True,
            "session_token":
            session_token,
            "expires":
            payload["expires"].isoformat(),
            "messages":
            ["Generated session_token successfully. Session initiated."],
        }

    except Exception as e:

        LOGGER.error("[%s] Could not create a new session for "
                     "user_id: %s with IP address: %s, user agent: %s. "
                     "Exception was: %r" % (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         pii_hash(payload["ip_address"], payload["pii_salt"]),
                         pii_hash(payload["user_agent"], payload["pii_salt"]),
                         e,
                     ))

        if raiseonfail:
            raise

        return {
            "failure_reason": "DB error when making new session",
            "success": False,
            "session_token": None,
            "expires": None,
            "messages": ["Could not create a new session."],
        }
Example #18
0
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,
        }
Example #19
0
def auth_session_delete(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """
    Removes a session token, effectively ending a session.

    Parameters
    ----------

    payload : dict
        This is a dict with the following required keys:

        - session_token: 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 with a success key indicating if the session was deleted
        successfully.

    """

    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 session delete request."],
            }

    if "session_token" not in payload:

        LOGGER.error(
            "[%s] Invalid session delete request, missing session_token." %
            payload["reqid"])

        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing 'session_token' in request"),
            "messages":
            ["Invalid session delete request. "
             "No session token provided."],
        }

    session_token = payload["session_token"]

    try:

        with engine.begin() as conn:

            sessions = meta.tables["sessions"]
            delete = sessions.delete().where(
                sessions.c.session_token == session_token)
            result = conn.execute(delete)
            success = result.rowcount == 1

        LOGGER.info(
            "[%s] Session delete request processed for "
            "session_token: %s, success: %s " % (
                payload["reqid"],
                pii_hash(payload["session_token"], payload["pii_salt"]),
                success,
            ))

        return {
            "success": success,
            "messages": ["Session delete processed, success: %s." % success],
        }

    except Exception as e:

        LOGGER.error(
            "[%s] Session delete request failed for "
            "session_token: %s. Exception was: %r." % (
                payload["reqid"],
                pii_hash(payload["session_token"], payload["pii_salt"]),
                e,
            ))

        if raiseonfail:
            raise

        return {
            "success": False,
            "failure_reason": "DB error when deleting session",
            "messages": ["Session could not be deleted."],
        }
Example #20
0
def send_forgotpass_verification_email(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """This actually sends the forgot password email.

    Parameters
    -----------

    payload : dict
        Keys expected in this dict from a client are:

        - email_address: str, the email address to send the email to
        - session_token: str, session token of the user being sent the email
        - server_name: str, the name of the frontend server
        - server_baseurl: str, the base URL of the frontend server
        - password_forgot_url: str, the URL fragment of the frontend
          forgot-password process initiation endpoint
        - verification_token: str, a verification token generated by frontend
        - verification_expiry: int, number of seconds after which the token
          expires

        In addition, the following items must be provided by a wrapper function
        to set up the email server.

        - emailuser
        - emailpass
        - emailserver
        - emailport
        - emailsender

        These can be provided as part of the payload as dict keys or as
        attributes in the SimpleNamespace object passed in the config kwarg. The
        config object will be checked first, and the payload items will override
        it.

        Finally, 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 the user_id, email_address, and the
        emailforgotpass_sent_datetime value if email was sent successfully.

    """

    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),
                "user_id":
                None,
                "email_address":
                None,
                "emailforgotpass_sent_datetime":
                None,
                "messages": ["Invalid forgot-password email request."],
            }

    for key in {
            "email_address",
            "session_token",
            "server_name",
            "server_baseurl",
            "password_forgot_url",
            "verification_token",
            "verification_expiry",
    }:

        if key not in payload:

            LOGGER.error("[%s] Invalid forgot-password request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "user_id":
                None,
                "email_address":
                None,
                "emailforgotpass_sent_datetime":
                None,
                "messages": (["Invalid forgot-password email request."]),
            }

    # now check for the SMTP server config items in the payload or in config
    if config is not None:
        emailsender = getattr(config, "emailsender", None)
        emailuser = getattr(config, "emailuser", None)
        emailpass = getattr(config, "emailpass", None)
        emailserver = getattr(config, "emailserver", None)
        emailport = getattr(config, "emailport", None)
    else:
        emailsender, emailuser, emailpass, emailserver, emailport = (
            None,
            None,
            None,
            None,
            None,
        )

    # override with payload values
    if "emailsender" in payload:
        emailsender = payload["emailsender"]
    if "emailuser" in payload:
        emailuser = payload["emailuser"]
    if "emailpass" in payload:
        emailpass = payload["emailpass"]
    if "emailserver" in payload:
        emailserver = payload["emailserver"]
    if "emailport" in payload:
        emailport = payload["emailport"]

    if emailsender is None or emailserver is None or emailport is None:

        LOGGER.error("[%s] Invalid email server settings "
                     "provided. Can't send an email." % payload["reqid"])
        return {
            "success":
            False,
            "failure_reason":
            ("missing 'emailserver', 'emailsender', 'emailport' in request"),
            "user_id":
            None,
            "email_address":
            None,
            "emailforgotpass_sent_datetime":
            None,
            "messages": ([
                "Invalid email server settings provided. "
                "Can't send an email."
            ]),
        }

    users = meta.tables["users"]
    user_sel = (select(
        users.c.user_id,
        users.c.email,
        users.c.is_active,
        users.c.user_role,
        users.c.emailforgotpass_sent_datetime,
    ).select_from(users).where(
        users.c.email == payload["email_address"]).where(
            users.c.is_active.is_(True)).where(
                users.c.user_role != "locked").where(
                    users.c.user_role != "anonymous"))

    with engine.begin() as conn:
        user_results = conn.execute(user_sel)
        user_info = user_results.first()

    if not user_info:

        LOGGER.error(
            "[%s] Forgot-password email request failed for "
            "email: %s, session_token: %s."
            "User matching the provided email address "
            "doesn't exist or is not active." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success": False,
            "failure_reason": "invalid user for forgot-pass email request",
            "user_id": None,
            "email_address": None,
            "emailforgotpass_sent_datetime": None,
            "messages": (["Invalid password reset email request."]),
        }

    # check the last time we sent a forgot password email to this user
    if user_info.emailforgotpass_sent_datetime is not None:

        check_elapsed = (datetime.utcnow() -
                         user_info.emailforgotpass_sent_datetime) > timedelta(
                             hours=24)

        if check_elapsed:
            send_forgotpass_email = True
        else:
            send_forgotpass_email = False

    # if we've never sent a forgot-password email before, it's OK to send it
    else:
        send_forgotpass_email = True

    if not send_forgotpass_email:

        LOGGER.error(
            "[%s] Forgot-password email request failed for "
            "email: %s, session_token: %s."
            "A forgot-password email was already sent to "
            "this user within the last 24 hours." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("forgot-pass verification email sent less than 24 hours ago"),
            "user_id":
            None,
            "email_address":
            None,
            "emailforgotpass_sent_datetime":
            None,
            "messages": (["Invalid password reset email request."]),
        }

    # check the session
    session_info = auth_session_exists(
        {
            "session_token": payload["session_token"],
            "pii_salt": payload["pii_salt"],
            "reqid": payload["reqid"],
        },
        raiseonfail=raiseonfail,
        override_authdb_path=override_authdb_path,
    )

    if not session_info["success"]:

        LOGGER.error(
            "[%s] Forgot-password email request failed for "
            "email: %s, session_token: %s."
            "The session associated with the request is not valid." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success": False,
            "failure_reason": "invalid session for forgot-pass request",
            "user_id": None,
            "email_address": None,
            "emailforgotpass_sent_datetime": None,
            "messages": (["Invalid verification email request."]),
        }

    #
    # finally! we'll process the email sending request
    #

    # get the IP address and browser ID from the session
    ip_addr = session_info["session_info"]["ip_address"]
    browser = session_info["session_info"]["user_agent"]

    # TODO: we'll use geoip to get the location of the person who initiated the
    # request.

    # get the verification token's expiry datetime
    verification_expiry_td = timedelta(seconds=payload["verification_expiry"])
    verification_expiry_dt = (datetime.utcnow() +
                              verification_expiry_td).isoformat()

    # format the verification token and wrap it to 70 chars per line because now
    # it's a bit too long for one line. we'll use a textbox on the verification
    # page to let people to paste this in
    if isinstance(payload["verification_token"], bytes):
        payload["verification_token"] = payload["verification_token"].decode(
            "utf-8")

    formatted_verification_token = "\n".join(
        textwrap.wrap(payload["verification_token"]))

    # generate the email message
    msgtext = FORGOTPASS_VERIFICATION_EMAIL_TEMPLATE.format(
        server_baseurl=payload["server_baseurl"],
        password_forgot_url=payload["password_forgot_url"],
        server_name=payload["server_name"],
        verification_code=formatted_verification_token,
        verification_expiry="%s (UTC time)" % verification_expiry_dt,
        browser_identifier=browser.replace("_", "."),
        ip_address=ip_addr,
        user_email=payload["email_address"],
    )
    recipients = [user_info.email]
    subject = FORGOTPASS_VERIFICATION_EMAIL_SUBJECT.format(
        server_name=payload["server_name"])

    # send the email
    email_sent = send_email(
        emailsender,
        subject,
        msgtext,
        recipients,
        emailserver,
        emailuser,
        emailpass,
        payload["pii_salt"],
        port=emailport,
    )

    if email_sent:

        emailforgotpass_sent_datetime = datetime.utcnow()
        update_vals = {
            "emailforgotpass_sent_datetime": emailforgotpass_sent_datetime
        }

        # finally, we'll update the users table with the actual
        # emailforgotpass_sent_datetime if sending succeeded.
        upd = (users.update().where(users.c.is_active.is_(True)).where(
            users.c.email == payload["email_address"]).values(update_vals))
        with engine.begin() as conn:
            result = conn.execute(upd)
            result.close()

        LOGGER.info(
            "[%s] Forgot-password email request succeeded for "
            "email: %s, session_token: %s. "
            "Email sent on: %s UTC." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
                emailforgotpass_sent_datetime.isoformat(),
            ))

        return {
            "success":
            True,
            "user_id":
            user_info.user_id,
            "email_address":
            user_info.email,
            "emailforgotpass_sent_datetime":
            emailforgotpass_sent_datetime,
            "messages":
            (["Password reset request sent successfully to %s" % recipients]),
        }

    else:

        LOGGER.error(
            "[%s] Forgot-password email request failed for "
            "email: %s, session_token: %s."
            "The email server could not send the "
            "email to the specified address." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason": ("forgot-pass verification email "
                               "could not be sent because email server issue"),
            "user_id":
            None,
            "email_address":
            None,
            "emailforgotpass_sent_datetime":
            None,
            "messages": ([
                "Could not send email to %s for "
                "the user password reset request." % recipients
            ]),
        }
Example #21
0
def auth_delete_sessions_userid(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """Removes all session tokens corresponding to a user ID.

    If keep_current_session is True, will not delete the session token passed in
    the payload. This allows for "delete all my other logins" functionality.

    Parameters
    ----------

    payload : dict
        This is a dict with the following required keys:

        - session_token: str
        - user_id: int
        - keep_current_session: bool

        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 with a success key indicating if the sessions were
        deleted successfully.

    """

    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 session delete request."],
            }

    for key in ("user_id", "session_token", "keep_current_session"):

        if key not in payload:

            LOGGER.error("[%s] Invalid session delete request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": [
                    "Missing or invalid parameters "
                    "auth_delete_sessions_userid."
                ],
            }

    user_id = payload["user_id"]
    session_token = payload["session_token"]
    keep_current_session = payload["session_token"]

    try:

        with engine.begin() as conn:

            sessions = meta.tables["sessions"]

            if keep_current_session:
                delete = (sessions.delete().where(
                    sessions.c.user_id == user_id).where(
                        sessions.c.session_token != session_token))

            else:
                delete = sessions.delete().where(sessions.c.user_id == user_id)

            result = conn.execute(delete)
            deleted_sessions = result.rowcount

        LOGGER.info("[%s] Session delete request processed for "
                    "user_id: %s, keep_current_session was set to %s, "
                    "deleted %s sessions" % (
                        payload["reqid"],
                        pii_hash(payload["user_id"], payload["pii_salt"]),
                        payload["keep_current_session"],
                        deleted_sessions,
                    ))

        return {
            "success":
            deleted_sessions > 0,
            "messages": [
                "Sessions delete processed. Success: %s." %
                (deleted_sessions > 0)
            ],
        }

    except Exception as e:

        LOGGER.error("[%s] Session delete request failed for "
                     "user_id: %s. Exception was: %s." % (
                         payload["reqid"],
                         pii_hash(payload["user_id"], payload["pii_salt"]),
                         e,
                     ))

        if raiseonfail:
            raise

        return {
            "success": False,
            "failure_reason": "DB error when updating session info",
            "messages": ["Sessions could not be deleted."],
        }
Example #22
0
def set_user_emailaddr_verified(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    config: SimpleNamespace = None,
) -> dict:
    """Sets the verification status of the email address of the user.

    This is called by the frontend after it verifies that the token challenge to
    verify the user's email succeeded and has not yet expired. This will set the
    user_role to 'authenticated' (or the previous user role before locking) and
    the is_active column to True.

    Parameters
    ----------

    payload : dict
        This is a dict with the following key:

        - email

        Finally, 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 the user_id, is_active, and user_role values
        if verification status is successfully set.

    """

    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),
                "user_id":
                None,
                "is_active":
                False,
                "user_role":
                "locked",
                "messages": ["Invalid email verification toggle request."],
            }

    if "email" not in payload:

        LOGGER.error(
            "[%s] Invalid email verification toggle request, missing %s." %
            (payload["reqid"], "email"))

        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing '%s' in request" % "email"),
            "user_id":
            None,
            "is_active":
            False,
            "user_role":
            "locked",
            "messages": ["Invalid email verification toggle request."],
        }

    users = meta.tables["users"]

    sel = (select(users.c.user_id, users.c.user_role,
                  users.c.extra_info).select_from(users).where(
                      users.c.email == payload["email"]))
    with engine.begin() as conn:
        result = conn.execute(sel)
        row = result.first()

    if row is None:
        LOGGER.error("[%s] Email verification toggle request failed for "
                     "email: %s."
                     "The database rows corresponding to "
                     "the user do not exist." % (
                         payload["reqid"],
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        return {
            "success": False,
            "failure_reason": "could not find user in DB by email",
            "user_id": None,
            "is_active": False,
            "user_role": "locked",
            "messages": ["Email verification toggle request failed."],
        }

    #
    # get the previous user_role if any
    #
    current_user_role = row.user_role
    user_extra_info = row.extra_info
    previous_user_roles = user_extra_info.get("previous_user_roles",
                                              ["locked"])

    if current_user_role != "locked":
        user_role_to_set = current_user_role
    elif previous_user_roles[-1] != "locked":
        user_role_to_set = previous_user_roles[-1]
    else:
        user_role_to_set = "authenticated"

    # update the table for this user
    upd = (users.update().where(users.c.is_active.is_(False)).where(
        users.c.email == payload["email"]).values({
            "is_active":
            True,
            "email_verified":
            True,
            "user_role":
            user_role_to_set,
        }))
    with engine.begin() as conn:
        conn.execute(upd)
        sel = (select(
            users.c.user_id,
            users.c.is_active,
            users.c.user_role,
        ).select_from(users).where((users.c.email == payload["email"])))
        result = conn.execute(sel)
        rows = result.first()

    if rows:

        LOGGER.info("[%s] Email verification toggle request succeeded for "
                    "user_id: %s, email: %s, role: %s, is_active: %s." % (
                        payload["reqid"],
                        pii_hash(rows.user_id, payload["pii_salt"]),
                        pii_hash(payload["email"], payload["pii_salt"]),
                        rows.user_role,
                        rows.is_active,
                    ))

        return {
            "success": True,
            "user_id": rows.user_id,
            "is_active": rows.is_active,
            "user_role": rows.user_role,
            "messages": ["Email verification toggle request succeeded."],
        }

    else:

        LOGGER.error("[%s] Email verification toggle request failed for "
                     "email: %s."
                     "The database rows corresponding to "
                     "the user could not be updated." % (
                         payload["reqid"],
                         pii_hash(payload["email"], payload["pii_salt"]),
                     ))

        return {
            "success": False,
            "failure_reason": "could not update user verified status in DB",
            "user_id": None,
            "is_active": False,
            "user_role": "locked",
            "messages": ["Email verification toggle request failed."],
        }
Example #23
0
def auth_kill_old_sessions(
    session_expiry_days: int = 7,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """
    Kills all expired sessions.

    Parameters
    ----------

    session_expiry_days : int
        All sessions older than the current datetime + this value will be
        deleted.

    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 with a success key indicating if the sessions were
        deleted successfully.

    """

    engine, meta, permjson, dbpath = get_procdb_permjson(
        override_authdb_path=override_authdb_path,
        override_permissions_json=None,
        raiseonfail=raiseonfail,
    )

    expires_days = session_expiry_days
    earliest_date = datetime.utcnow() - timedelta(days=expires_days)

    with engine.begin() as conn:

        sessions = meta.tables["sessions"]

        sel = (select(
            sessions.c.session_token,
            sessions.c.created,
            sessions.c.expires,
        ).select_from(sessions).where(sessions.c.expires < earliest_date))

        result = conn.execute(sel)
        rows = result.fetchall()

        if len(rows) > 0:

            LOGGER.warning("Will kill %s sessions older than %sZ." %
                           (len(rows), earliest_date.isoformat()))

            delete = sessions.delete().where(
                sessions.c.expires < earliest_date)
            result = conn.execute(delete)
            success = result.rowcount > 0

            return {
                "success":
                True,
                "messages": [
                    "delete for %s sessions older than %sZ processed, "
                    "success: %s" %
                    (len(rows), earliest_date.isoformat(), success)
                ],
            }

        else:

            LOGGER.warning("No sessions older than %sZ found to delete." %
                           earliest_date.isoformat())
            return {
                "success":
                False,
                "failure_reason":
                "no sessions found to delete",
                "messages": [
                    "No sessions older than %sZ found to delete" %
                    earliest_date.isoformat()
                ],
            }
Example #24
0
def auth_user_login(
    payload: dict,
    override_authdb_path: str = None,
    raiseonfail: bool = False,
    config: SimpleNamespace = None,
) -> dict:
    """Logs a user in.

    Login flow for frontend:

    session cookie get -> check session exists -> check user login -> old
    session delete (no matter what) -> new session create (with actual user_id
    and other info now included if successful or same user_id = anon if not
    successful) -> done

    The frontend MUST unset the cookie as well.

    FIXME: update (and fake-update) the Users table with the last_login_try and
    last_login_success.

    Parameters
    ----------

    payload : dict
        The payload dict should contain the following keys:

        - session_token: str
        - email: str
        - 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
        The SQLAlchemy database URL to use if not using the default auth DB.

    raiseonfail : bool
        If True, and something goes wrong, this will raise an Exception instead
        of returning normally with a failure condition.

    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 the result of the password verification check.

    """

    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),
                "user_id":
                None,
                "messages": ["Invalid user login request."],
            }

    # check broken
    request_ok = True
    for item in ("email", "password", "session_token"):
        if item not in payload:
            request_ok = False
            break

    users = meta.tables["users"]

    #
    # check if the request is OK
    #

    # if it isn't, then hash the dummy user's password twice
    if not request_ok:

        # dummy session request
        auth_session_exists(
            {
                "session_token": "nope",
                "reqid": payload["reqid"],
                "pii_salt": payload["pii_salt"],
            },
            raiseonfail=raiseonfail,
            override_authdb_path=override_authdb_path,
        )

        # get the dummy user's password from the DB on an outright failure -
        # run this twice to match the number of verifications for a normal
        # successful user
        dummy_sel = (select(
            users.c.password).select_from(users).where(users.c.user_id == 3))
        with engine.begin() as conn:
            for _ in range(2):
                dummy_results = conn.execute(dummy_sel)
                dummy_password = dummy_results.scalar()
                try:
                    pass_hasher.verify(dummy_password, "nope")
                except Exception:
                    pass

        # run a fake session delete
        auth_session_delete(
            {
                "session_token": "nope",
                "reqid": payload["reqid"],
                "pii_salt": payload["pii_salt"],
            },
            raiseonfail=raiseonfail,
            override_authdb_path=override_authdb_path,
        )

        LOGGER.error(
            "[%s] User login failed for session_token: %s and "
            "provided email address: %s. "
            "Missing request items." % (
                payload["reqid"],
                pii_hash(payload["session_token"], payload["pii_salt"]),
                pii_hash(payload["email"], payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            ("invalid request: missing "
             "'session_token', 'email', or 'password' in request"),
            "user_id":
            None,
            "messages": ["No session token provided."],
        }

    # otherwise, now we'll check if the session exists
    else:

        session_info = auth_session_exists(
            {
                "session_token": payload["session_token"],
                "reqid": payload["reqid"],
                "pii_salt": payload["pii_salt"],
            },
            raiseonfail=raiseonfail,
            override_authdb_path=override_authdb_path,
        )

        # if it doesn't, hash the dummy password twice
        if not session_info["success"]:

            # get the dummy user's password from the DB on an outright failure -
            # run this twice to match the number of verifications for a normal
            # successful user
            dummy_sel = (select(users.c.password).select_from(users).where(
                users.c.user_id == 3))
            with engine.begin() as conn:
                for _ in range(2):
                    dummy_results = conn.execute(dummy_sel)
                    dummy_password = dummy_results.scalar()
                    try:
                        pass_hasher.verify(dummy_password, "nope")
                    except Exception:
                        pass

            # run a fake session delete
            auth_session_delete(
                {
                    "session_token": "nope",
                    "reqid": payload["reqid"],
                    "pii_salt": payload["pii_salt"],
                },
                raiseonfail=raiseonfail,
                override_authdb_path=override_authdb_path,
            )

            LOGGER.error(
                "[%s] User login failed for session_token: %s and "
                "email address: %s. "
                "The session token provided does not exist." % (
                    payload["reqid"],
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                    pii_hash(payload["email"], payload["pii_salt"]),
                ))

            return {
                "success": False,
                "failure_reason": "session does not exist",
                "user_id": None,
                "messages": ["No session token provided."],
            }

        # if the session token does exist, we'll proceed to checking the
        # password for the provided email
        else:

            # get the dummy user's password from the DB and hash it
            dummy_sel = (select(users.c.password).select_from(users).where(
                users.c.user_id == 3))
            with engine.begin() as conn:
                dummy_results = conn.execute(dummy_sel)
                dummy_password = dummy_results.scalar()
                try:
                    pass_hasher.verify(dummy_password, "nope")
                except Exception:
                    pass

            # run a fake session delete
            auth_session_delete(
                {
                    "session_token": "nope",
                    "reqid": payload["reqid"],
                    "pii_salt": payload["pii_salt"],
                },
                raiseonfail=raiseonfail,
                override_authdb_path=override_authdb_path,
            )

            # actually look up the provided user
            user_sel = (select(
                users.c.user_id,
                users.c.password,
                users.c.is_active,
                users.c.user_role,
            ).select_from(users).where(
                users.c.email == payload["email"]).where(
                    users.c.is_active.is_(True)).where(
                        users.c.email_verified.is_(True)))

            with engine.begin() as conn:
                user_results = conn.execute(user_sel)
                user_info = user_results.first()

            if user_info:

                try:

                    pass_ok = pass_hasher.verify(
                        user_info.password,
                        payload["password"][:256],
                    )

                except Exception as e:

                    LOGGER.error(
                        "[%s] User login failed for session_token: %s and "
                        "email address: %s. "
                        "The password provided does not match the one on "
                        "record for user_id: %s. Exception was: %r" % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(payload["email"], payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                            e,
                        ))
                    pass_ok = False

            else:
                pass_ok = False

            # run a session delete on the provided token. the frontend will
            # always re-ask for a new session token on the next request after
            # login if it fails or succeeds.
            auth_session_delete(
                {
                    "session_token": payload["session_token"],
                    "reqid": payload["reqid"],
                    "pii_salt": payload["pii_salt"],
                },
                raiseonfail=raiseonfail,
                override_authdb_path=override_authdb_path,
            )

            if not pass_ok:

                return {
                    "success":
                    False,
                    "failure_reason":
                    ("user does not exist or password doesn't match"),
                    "user_id":
                    None,
                    "messages": [
                        "Sorry, that user ID and "
                        "password combination didn't work."
                    ],
                }

            # if password verification succeeeded, check if the user can
            # actually log in (i.e. their account is not locked or is not
            # inactive)
            else:

                # we now check if the plain-text password provided to us needs
                # to be rehashed with newer parameters. this is useful when the
                # argon library updates its defaults. this is also needed when
                # we update our own values for the work factor, etc. parameters
                # when someone invents a better GPU password cracker machine.

                # check the stored hashed password's parameters
                pass_needs_rehash = pass_hasher.check_needs_rehash(
                    user_info.password)

                # if they need to be updated, rehash the plain-text password
                # provided to us with the newer parameters and store it
                if pass_needs_rehash:

                    # rehash and store the new password
                    rehashed_password = pass_hasher.hash(
                        payload["password"][:256])

                    # update the table for this user
                    upd = (users.update().where(
                        users.c.user_id == user_info.user_id).where(
                            users.c.email == payload["email"]).values(
                                {"password": rehashed_password}))
                    with engine.begin() as conn:
                        conn.execute(upd)

                    LOGGER.warning(
                        "[%s] Password rehashed for user "
                        "because Argon2 parameters "
                        "changed for session_token: %s and "
                        "email address: %s. "
                        "Matched user with user_id: %s. " % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(payload["email"], payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                        ))

                # if the user account is active and unlocked, proceed.
                # the frontend will take this user_id and ask for a new session
                # token with it.
                if user_info.is_active and user_info.user_role != "locked":

                    LOGGER.info(
                        "[%s] User login successful for session_token: %s and "
                        "email address: %s. "
                        "Matched user with user_id: %s. " % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(payload["email"], payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                        ))

                    return {
                        "success": True,
                        "user_id": user_info.user_id,
                        "user_role": user_info.user_role,
                        "messages": ["Login successful."],
                    }

                # if the user account is locked, return a failure
                else:

                    LOGGER.error(
                        "[%s] User login failed for session_token: %s and "
                        "email address: %s. "
                        "Matched user with user_id: %s is not active "
                        "or is locked." % (
                            payload["reqid"],
                            pii_hash(payload["session_token"],
                                     payload["pii_salt"]),
                            pii_hash(payload["email"], payload["pii_salt"]),
                            pii_hash(user_info.user_id, payload["pii_salt"]),
                        ))

                    return {
                        "success":
                        False,
                        "failure_reason":
                        "user exists but is inactive",
                        "user_id":
                        user_info.user_id,
                        "messages": [
                            "Sorry, that user ID and "
                            "password combination didn't work."
                        ],
                    }
Example #25
0
def verify_password_reset(
    payload: dict,
    raiseonfail: bool = False,
    override_authdb_path: str = None,
    min_pass_length: int = 12,
    max_unsafe_similarity: int = 33,
    config: SimpleNamespace = None,
) -> dict:
    """
    Verifies a password reset request.

    Parameters
    ----------

    payload : dict
        This is a dict with the following required keys:

        - email_address: str
        - new_password: str
        - session_token: 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

    raiseonfail : bool
        If True, will raise an Exception if something goes wrong.

    override_authdb_path : str or None
        If given as a str, is the alternative path to the auth DB.

    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 containing a success key indicating if the user's
        password was reset.

    """

    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 password reset request."],
            }

    for key in ("email_address", "new_password", "session_token"):

        if key not in payload:

            LOGGER.error("[%s] Invalid password reset request, missing %s." %
                         (payload["reqid"], key))

            return {
                "success":
                False,
                "failure_reason":
                ("invalid request: missing '%s' in request" % key),
                "messages": [
                    "Invalid password reset request. "
                    "Some required parameters are missing."
                ],
            }

    users = meta.tables["users"]

    # check the session
    session_info = auth_session_exists(
        {
            "session_token": payload["session_token"],
            "pii_salt": payload["pii_salt"],
            "reqid": payload["reqid"],
        },
        raiseonfail=raiseonfail,
        override_authdb_path=override_authdb_path,
    )

    if not session_info["success"]:

        LOGGER.error(
            "[%s] Password reset request failed for "
            "email: %s, session_token: %s. "
            "Provided session token was not found in the DB or has expired." %
            (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success": False,
            "failure_reason": "session does not exist",
            "messages":
            (["Invalid session token for password reset request."]),
        }

    with engine.begin() as conn:

        sel = (select(
            users.c.user_id,
            users.c.full_name,
            users.c.email,
            users.c.password,
        ).select_from(users).where(
            users.c.email == payload["email_address"]).where(
                users.c.is_active.is_(True)))

        result = conn.execute(sel)
        user_info = result.first()

    if not user_info or len(user_info) == 0:

        LOGGER.error(
            "[%s] Password reset request failed for "
            "email: %s, session_token: %s. "
            "User email was not found in the DB or the user is inactive." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
            ))

        return {
            "success": False,
            "failure_reason": "user does not exist",
            "messages": ["Invalid user for password reset request."],
        }

    # let's hash the new password against the current password
    new_password = payload["new_password"][:256]

    try:
        pass_same = pass_hasher.verify(
            user_info.password,
            new_password,
        )
    except Exception:
        pass_same = False

    # don't fail here, but note that the user is re-using the password they
    # forgot. FIXME: should we actually fail here?
    if pass_same:

        LOGGER.warning(
            "[%s] Password reset request warning for "
            "email: %s, session_token: %s, user_id: %s. "
            "User is attempting to reuse the password they supposedly forgot."
            % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
                pii_hash(user_info.user_id, payload["pii_salt"]),
            ))

    # hash the user's password
    hashed_password = pass_hasher.hash(new_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(
        user_info.full_name,
        payload["email_address"],
        new_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] Password reset request failed for "
            "email: %s, session_token: %s, user_id: %s. "
            "The new password is insecure." % (
                payload["reqid"],
                pii_hash(payload["email_address"], payload["pii_salt"]),
                pii_hash(payload["session_token"], payload["pii_salt"]),
                pii_hash(user_info.user_id, payload["pii_salt"]),
            ))

        return {
            "success":
            False,
            "failure_reason":
            "invalid password",
            "messages":
            (["Insecure password for password reset request."] + messages),
        }

    # if the password passes validation, hash it and store it
    else:

        with engine.begin() as conn:

            # update the table for this user
            upd = (users.update().where(
                users.c.user_id == user_info.user_id).where(
                    users.c.is_active.is_(True)).where(
                        users.c.email == payload["email_address"]).values(
                            {"password": hashed_password}))
            conn.execute(upd)

            sel = (select(users.c.password, ).select_from(users).where(
                (users.c.email == payload["email_address"])))
            result = conn.execute(sel)
            rows = result.first()

        if rows and rows.password == hashed_password:

            LOGGER.info(
                "[%s] Password reset request succeeded for "
                "email: %s, session_token: %s, user_id: %s. " % (
                    payload["reqid"],
                    pii_hash(payload["email_address"], payload["pii_salt"]),
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                    pii_hash(user_info.user_id, payload["pii_salt"]),
                ))

            messages.append("Password changed successfully.")
            return {"success": True, "messages": messages}

        else:

            LOGGER.error(
                "[%s] Password reset request failed for "
                "email: %s, session_token: %s, user_id: %s. "
                "The database row for the user could not be updated." % (
                    payload["reqid"],
                    pii_hash(payload["email_address"], payload["pii_salt"]),
                    pii_hash(payload["session_token"], payload["pii_salt"]),
                    pii_hash(user_info.user_id, payload["pii_salt"]),
                ))

            messages.append("Password could not be changed.")
            return {
                "success": False,
                "failure_reason": "password update failed in DB",
                "messages": messages,
            }