Source code for authnzerver.actions.session

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

"""This contains functions to drive session-related auth actions.

"""

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

import logging
from types import SimpleNamespace

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


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

try:

    from datetime import datetime, timezone, timedelta

    utc = timezone.utc

except Exception:

    from datetime import datetime, timedelta, tzinfo

    ZERO = timedelta(0)

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

        def utcoffset(self, dt):
            return ZERO

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

        def dst(self, dt):
            return ZERO

    utc = UTC()

import ipaddress
import secrets

from sqlalchemy import select, insert

from ..permissions import pii_hash
from authnzerver.actions.utils import get_procdb_permjson


################################
## SESSION HANDLING FUNCTIONS ##
################################


[docs]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."], }
[docs]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."], }
[docs]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."], }
[docs]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."], }
[docs]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."], }
[docs]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() ], }