Source code for camcops_server.cc_modules.cc_session

#!/usr/bin/env python
# camcops_server/cc_modules/cc_session.py

"""
===============================================================================

    Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <http://www.gnu.org/licenses/>.

===============================================================================
"""

import logging
from typing import Optional, TYPE_CHECKING

from cardinal_pythonlib.datetimefunc import format_datetime
from cardinal_pythonlib.reprfunc import simple_repr
from cardinal_pythonlib.logs import BraceStyleAdapter
from cardinal_pythonlib.randomness import create_base64encoded_randomness
from pendulum import DateTime as Pendulum
from pyramid.interfaces import ISession
from sqlalchemy.orm import relationship, Session as SqlASession
from sqlalchemy.sql.schema import Column, ForeignKey
from sqlalchemy.sql.sqltypes import DateTime, Integer

from .cc_constants import DateFormat
from .cc_pyramid import CookieKey
from .cc_sqla_coltypes import IPAddressColType, SessionTokenColType
from .cc_sqlalchemy import Base
from .cc_taskfilter import TaskFilter
from .cc_unittest import DemoDatabaseTestCase
from .cc_user import SecurityAccountLockout, SecurityLoginFailure, User

if TYPE_CHECKING:
    from .cc_request import CamcopsRequest
    from .cc_tabletsession import TabletSession

log = BraceStyleAdapter(logging.getLogger(__name__))


# =============================================================================
# Debugging options
# =============================================================================

DEBUG_CAMCOPS_SESSION_CREATION = False

if DEBUG_CAMCOPS_SESSION_CREATION:
    log.warning("Debugging options enabled!")

# =============================================================================
# Constants
# =============================================================================

DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25


# =============================================================================
# Security for web sessions
# =============================================================================

[docs]def generate_token(num_bytes: int = 16) -> str: """ Make a new session token that's not in use. It doesn't matter if it's already in use by a session with a different ID, because the ID/token pair is unique. (Removing that constraint gets rid of an in-principle-but-rare locking problem.) """ # http://stackoverflow.com/questions/817882/unique-session-id-in-python return create_base64encoded_randomness(num_bytes)
# ============================================================================= # Session class # =============================================================================
[docs]class CamcopsSession(Base): """ Class representing an HTTPS session. """ __tablename__ = "_security_webviewer_sessions" # no TEXT fields here; this is a performance-critical table id = Column( "id", Integer, primary_key=True, autoincrement=True, index=True, comment="Session ID (internal number for insertion speed)" ) token = Column( "token", SessionTokenColType, comment="Token (base 64 encoded random number)" ) ip_address = Column( "ip_address", IPAddressColType, comment="IP address of user" ) user_id = Column( "user_id", Integer, ForeignKey("_security_users.id", ondelete="CASCADE"), # http://docs.sqlalchemy.org/en/latest/core/constraints.html#on-update-and-on-delete # noqa comment="User ID" ) last_activity_utc = Column( "last_activity_utc", DateTime, comment="Date/time of last activity (UTC)" ) number_to_view = Column( "number_to_view", Integer, comment="Number of records to view" ) task_filter_id = Column( "task_filter_id", Integer, ForeignKey("_task_filters.id"), comment="Task filter ID" ) user = relationship("User", lazy="joined", foreign_keys=[user_id]) task_filter = relationship("TaskFilter", foreign_keys=[task_filter_id], cascade="save-update, merge, delete") # ... "save-update, merge" is the default. We are adding "delete", which # means that when this CamcopsSession is deleted, the corresponding # TaskFilter will be deleted as well. See # http://docs.sqlalchemy.org/en/latest/orm/cascades.html#delete # ------------------------------------------------------------------------- # Basic info # ------------------------------------------------------------------------- def __repr__(self) -> str: return simple_repr( self, ["id", "token", "ip_address", "user_id", "last_activity_utc_iso", "user"], with_addr=True ) @property def last_activity_utc_iso(self) -> str: return format_datetime(self.last_activity_utc, DateFormat.ISO8601) # ------------------------------------------------------------------------- # Creating sessions # -------------------------------------------------------------------------
[docs] @classmethod def get_session_using_cookies(cls, req: "CamcopsRequest") -> "CamcopsSession": """ Makes, or retrieves, a new CamcopsSession for this Pyramid Request. """ pyramid_session = req.session # type: ISession # noinspection PyArgumentList session_id_str = pyramid_session.get(CookieKey.SESSION_ID, '') # noinspection PyArgumentList session_token = pyramid_session.get(CookieKey.SESSION_TOKEN, '') return cls.get_session(req, session_id_str, session_token)
@classmethod def get_session_for_tablet(cls, ts: "TabletSession") -> "CamcopsSession": def login_from_ts(cc: "CamcopsSession", ts_: "TabletSession") -> None: if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("Considering login from tablet (with username: {!r}", ts_.username) if ts_.username: user = User.get_user_from_username_password( ts.req, ts.username, ts.password) if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("... looked up User: {!r}", user) if user: # Successful login of sorts, ALTHOUGH the user may be # severely restricted (if they can neither register nor # upload). However, effecting a "login" here means that the # error messages can become more helpful! cc.login(user) if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("... final session user: {!r}", cc.user) session = cls.get_session(req=ts.req, session_id_str=ts.session_id, session_token=ts.session_token) if not session.user: login_from_ts(session, ts) elif session.user and session.user.username != ts.username: # We found a session, and it's associated with a user, but with # the wrong user. This is unlikely to happen! # Wipe the old one: req = ts.req session.logout(req) # Create a fresh session. session = cls.get_session(req=req, session_id_str=None, session_token=None) login_from_ts(session, ts) return session
[docs] @classmethod def get_session(cls, req: "CamcopsRequest", session_id_str: Optional[str], session_token: Optional[str]) -> 'CamcopsSession': """ Retrieves, or makes, a new CamcopsSession for this Pyramid Request, for a specific session_id and session_token. """ if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("CamcopsSession.get_session: session_id_str={!r}, " "session_token={!r}", session_id_str, session_token) # --------------------------------------------------------------------- # Starting variables # --------------------------------------------------------------------- try: session_id = int(session_id_str) except (TypeError, ValueError): session_id = None dbsession = req.dbsession ip_addr = req.remote_addr now = req.now_utc # --------------------------------------------------------------------- # Fetch or create # --------------------------------------------------------------------- if session_id and session_token: oldest_last_activity_allowed = \ cls.get_oldest_last_activity_allowed(req) candidate = dbsession.query(cls).\ filter(cls.id == session_id).\ filter(cls.token == session_token).\ filter(cls.ip_address == ip_addr).\ filter(cls.last_activity_utc >= oldest_last_activity_allowed).\ first() # type: Optional[CamcopsSession] if DEBUG_CAMCOPS_SESSION_CREATION: if candidate is None: log.debug("Session not found in database") else: if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("Session ID and/or session token is missing.") candidate = None found = candidate is not None if found: candidate.last_activity_utc = now ccsession = candidate else: new_http_session = cls(ip_addr=ip_addr, last_activity_utc=now) dbsession.add(new_http_session) if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("Creating new CamcopsSession: {!r}", new_http_session) # But we DO NOT FLUSH and we DO NOT SET THE COOKIES YET, because # we might hot-swap the session. # See complete_request_add_cookies(). ccsession = new_http_session return ccsession
@classmethod def get_oldest_last_activity_allowed( cls, req: "CamcopsRequest") -> Pendulum: cfg = req.config now = req.now_utc oldest_last_activity_allowed = now - cfg.session_timeout return oldest_last_activity_allowed
[docs] @classmethod def delete_old_sessions(cls, req: "CamcopsRequest") -> None: """Delete all expired sessions.""" oldest_last_activity_allowed = \ cls.get_oldest_last_activity_allowed(req) dbsession = req.dbsession log.info("Deleting expired sessions") dbsession.query(cls)\ .filter(cls.last_activity_utc < oldest_last_activity_allowed)\ .delete(synchronize_session=False)
def __init__(self, ip_addr: str = None, last_activity_utc: Pendulum = None): self.token = generate_token() self.ip_address = ip_addr self.last_activity_utc = last_activity_utc # ------------------------------------------------------------------------- # User info and login/logout # ------------------------------------------------------------------------- @property def username(self) -> Optional[str]: if self.user: return self.user.username return None
[docs] def logout(self, req: "CamcopsRequest") -> None: """ Log out, wiping session details. Also, perform periodic maintenance for the server, as this is a good time. """ # First, the logout process. self.user_id = None self.token = '' # so there's no way this token can be re-used # Secondly, some other things unrelated to logging out. Users will not # always log out manually. But sometimes they will. So we may as well # do some slow non-critical things: self.delete_old_sessions(req) SecurityAccountLockout.delete_old_account_lockouts(req) SecurityLoginFailure.clear_dummy_login_failures_if_necessary(req)
# send_analytics_if_necessary(req)
[docs] def login(self, user: User) -> None: """ Log in. Associates the user with the session and makes a new token. """ if DEBUG_CAMCOPS_SESSION_CREATION: log.debug("Session {} login: username={!r}", self.id, user.username) self.user = user # will set our user_id FK self.token = generate_token()
# fresh token: https://www.owasp.org/index.php/Session_fixation # ------------------------------------------------------------------------- # Filters # ------------------------------------------------------------------------- def get_task_filter(self) -> TaskFilter: if not self.task_filter: dbsession = SqlASession.object_session(self) assert dbsession, ( "CamcopsSession.get_task_filter() called on a CamcopsSession " "that's not yet in a session") self.task_filter = TaskFilter() dbsession.add(self.task_filter) return self.task_filter
# ============================================================================= # Unit tests # =============================================================================
[docs]class SessionTests(DemoDatabaseTestCase): def test_sessions(self) -> None: self.announce("test_sessions") req = self.req dbsession = self.dbsession self.assertIsInstance(generate_token(), str) CamcopsSession.delete_old_sessions(req) self.assertIsInstance( CamcopsSession.get_oldest_last_activity_allowed(req), Pendulum) s = req.camcops_session u = self.dbsession.query(User).first() # type: User assert u, "Missing user in demo database!" self.assertIsInstance(s.last_activity_utc_iso, str) self.assertIsInstanceOrNone(s.username, str) s.logout(req) s.login(u) self.assertIsInstance(s.get_task_filter(), TaskFilter) # Now test deletion cascade dbsession.commit() numfilters = dbsession.query(TaskFilter).count() assert numfilters == 1, "TaskFilter count should be 1" dbsession.delete(s) dbsession.commit() numfilters = dbsession.query(TaskFilter).count() assert numfilters == 0, ( "TaskFilter count should be 0; cascade delete not working" )