Coverage for cc_modules/cc_session.py : 38%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_session.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27**Implements sessions for web clients (humans).**
29"""
31import logging
32from typing import Optional, TYPE_CHECKING
34from cardinal_pythonlib.datetimefunc import (
35 format_datetime,
36 pendulum_to_utc_datetime_without_tz,
37)
38from cardinal_pythonlib.reprfunc import simple_repr
39from cardinal_pythonlib.logs import BraceStyleAdapter
40from cardinal_pythonlib.randomness import create_base64encoded_randomness
41from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery
42from pendulum import DateTime as Pendulum
43from pyramid.interfaces import ISession
44from sqlalchemy.orm import relationship, Session as SqlASession
45from sqlalchemy.sql.schema import Column, ForeignKey
46from sqlalchemy.sql.sqltypes import Boolean, DateTime, Integer
48from camcops_server.cc_modules.cc_constants import DateFormat
49from camcops_server.cc_modules.cc_pyramid import CookieKey
50from camcops_server.cc_modules.cc_sqla_coltypes import (
51 IPAddressColType,
52 SessionTokenColType,
53)
54from camcops_server.cc_modules.cc_sqlalchemy import Base
55from camcops_server.cc_modules.cc_taskfilter import TaskFilter
56from camcops_server.cc_modules.cc_user import (
57 User,
58)
60if TYPE_CHECKING:
61 from camcops_server.cc_modules.cc_request import CamcopsRequest
62 from camcops_server.cc_modules.cc_tabletsession import TabletSession
64log = BraceStyleAdapter(logging.getLogger(__name__))
67# =============================================================================
68# Debugging options
69# =============================================================================
71DEBUG_CAMCOPS_SESSION_CREATION = False
73if DEBUG_CAMCOPS_SESSION_CREATION:
74 log.warning("cc_session: Debugging options enabled!")
76# =============================================================================
77# Constants
78# =============================================================================
80DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25
83# =============================================================================
84# Security for web sessions
85# =============================================================================
87def generate_token(num_bytes: int = 16) -> str:
88 """
89 Make a new session token that's not in use.
91 It doesn't matter if it's already in use by a session with a different ID,
92 because the ID/token pair is unique. (Removing that constraint gets rid of
93 an in-principle-but-rare locking problem.)
94 """
95 # http://stackoverflow.com/questions/817882/unique-session-id-in-python
96 return create_base64encoded_randomness(num_bytes)
99# =============================================================================
100# Session class
101# =============================================================================
103class CamcopsSession(Base):
104 """
105 Class representing an HTTPS session.
106 """
107 __tablename__ = "_security_webviewer_sessions"
109 # no TEXT fields here; this is a performance-critical table
110 id = Column(
111 "id", Integer,
112 primary_key=True, autoincrement=True, index=True,
113 comment="Session ID (internal number for insertion speed)"
114 )
115 token = Column(
116 "token", SessionTokenColType,
117 comment="Token (base 64 encoded random number)"
118 )
119 ip_address = Column(
120 "ip_address", IPAddressColType,
121 comment="IP address of user"
122 )
123 user_id = Column(
124 "user_id", Integer,
125 ForeignKey("_security_users.id", ondelete="CASCADE"),
126 # http://docs.sqlalchemy.org/en/latest/core/constraints.html#on-update-and-on-delete # noqa
127 comment="User ID"
128 )
129 last_activity_utc = Column(
130 "last_activity_utc", DateTime,
131 comment="Date/time of last activity (UTC)"
132 )
133 number_to_view = Column(
134 "number_to_view", Integer,
135 comment="Number of records to view"
136 )
137 task_filter_id = Column(
138 "task_filter_id", Integer,
139 ForeignKey("_task_filters.id"),
140 comment="Task filter ID"
141 )
142 is_api_session = Column(
143 "is_api_session", Boolean,
144 default=False,
145 comment="This session is using the client API (not a human browsing)."
146 )
148 user = relationship("User", lazy="joined", foreign_keys=[user_id])
149 task_filter = relationship(
150 "TaskFilter", foreign_keys=[task_filter_id],
151 cascade="all, delete-orphan",
152 single_parent=True)
153 # ... "save-update, merge" is the default. We are adding "delete", which
154 # means that when this CamcopsSession is deleted, the corresponding
155 # TaskFilter will be deleted as well. See
156 # http://docs.sqlalchemy.org/en/latest/orm/cascades.html#delete
157 # ... 2020-09-22: changed to "all, delete-orphan" and single_parent=True
158 # https://docs.sqlalchemy.org/en/13/orm/cascades.html#cascade-delete-orphan
159 # https://docs.sqlalchemy.org/en/13/errors.html#error-bbf0
161 # -------------------------------------------------------------------------
162 # Basic info
163 # -------------------------------------------------------------------------
165 def __repr__(self) -> str:
166 return simple_repr(
167 self,
168 ["id", "token", "ip_address", "user_id", "last_activity_utc_iso",
169 "user"],
170 with_addr=True
171 )
173 @property
174 def last_activity_utc_iso(self) -> str:
175 """
176 Returns a formatted version of the date/time at which the last
177 activity took place for this session.
178 """
179 return format_datetime(self.last_activity_utc, DateFormat.ISO8601)
181 # -------------------------------------------------------------------------
182 # Creating sessions
183 # -------------------------------------------------------------------------
185 @classmethod
186 def get_session_using_cookies(cls,
187 req: "CamcopsRequest") -> "CamcopsSession":
188 """
189 Makes, or retrieves, a new
190 :class:`camcops_server.cc_modules.cc_session.CamcopsSession` for this
191 Pyramid Request.
193 The session is found using the ID/token information in the request's
194 cookies.
195 """
196 pyramid_session = req.session # type: ISession
197 # noinspection PyArgumentList
198 session_id_str = pyramid_session.get(CookieKey.SESSION_ID, '')
199 # noinspection PyArgumentList
200 session_token = pyramid_session.get(CookieKey.SESSION_TOKEN, '')
201 return cls.get_session(req, session_id_str, session_token)
203 @classmethod
204 def get_session_for_tablet(cls, ts: "TabletSession") -> "CamcopsSession":
205 """
206 For a given
207 :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession` (used
208 by tablet client devices), returns a corresponding
209 :class:`camcops_server.cc_modules.cc_session.CamcopsSession`.
211 This also performs user authorization.
213 User authentication is via the
214 :class:`camcops_server.cc_modules.cc_session.CamcopsSession`.
215 """
216 session = cls.get_session(req=ts.req,
217 session_id_str=ts.session_id,
218 session_token=ts.session_token)
219 if not session.user:
220 session._login_from_ts(ts)
221 elif session.user and session.user.username != ts.username:
222 # We found a session, and it's associated with a user, but with
223 # the wrong user. This is unlikely to happen!
224 # Wipe the old one:
225 req = ts.req
226 session.logout()
227 # Create a fresh session.
228 session = cls.get_session(req=req, session_id_str=None,
229 session_token=None)
230 session._login_from_ts(ts)
231 return session
233 def _login_from_ts(self, ts: "TabletSession") -> None:
234 """
235 Used by :meth:`get_session_for_tablet` to log in using information
236 provided by a
237 :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession`.
238 """
239 if DEBUG_CAMCOPS_SESSION_CREATION:
240 log.debug("Considering login from tablet (with username: {!r}",
241 ts.username)
242 self.is_api_session = True
243 if ts.username:
244 user = User.get_user_from_username_password(
245 ts.req, ts.username, ts.password)
246 if DEBUG_CAMCOPS_SESSION_CREATION:
247 log.debug("... looked up User: {!r}", user)
248 if user:
249 # Successful login of sorts, ALTHOUGH the user may be
250 # severely restricted (if they can neither register nor
251 # upload). However, effecting a "login" here means that the
252 # error messages can become more helpful!
253 self.login(user)
254 if DEBUG_CAMCOPS_SESSION_CREATION:
255 log.debug("... final session user: {!r}", self.user)
257 @classmethod
258 def get_session(cls,
259 req: "CamcopsRequest",
260 session_id_str: Optional[str],
261 session_token: Optional[str]) -> 'CamcopsSession':
262 """
263 Retrieves, or makes, a new
264 :class:`camcops_server.cc_modules.cc_session.CamcopsSession` for this
265 Pyramid Request, given a specific ``session_id`` and ``session_token``.
266 """
267 if DEBUG_CAMCOPS_SESSION_CREATION:
268 log.debug("CamcopsSession.get_session: session_id_str={!r}, "
269 "session_token={!r}", session_id_str, session_token)
270 # ---------------------------------------------------------------------
271 # Starting variables
272 # ---------------------------------------------------------------------
273 try:
274 session_id = int(session_id_str)
275 except (TypeError, ValueError):
276 session_id = None
277 dbsession = req.dbsession
278 ip_addr = req.remote_addr
279 now = req.now_utc
281 # ---------------------------------------------------------------------
282 # Fetch or create
283 # ---------------------------------------------------------------------
284 if session_id and session_token:
285 oldest_last_activity_allowed = \
286 cls.get_oldest_last_activity_allowed(req)
287 candidate = dbsession.query(cls).\
288 filter(cls.id == session_id).\
289 filter(cls.token == session_token).\
290 filter(cls.ip_address == ip_addr).\
291 filter(cls.last_activity_utc >= oldest_last_activity_allowed).\
292 first() # type: Optional[CamcopsSession]
293 if DEBUG_CAMCOPS_SESSION_CREATION:
294 if candidate is None:
295 log.debug("Session not found in database")
296 else:
297 if DEBUG_CAMCOPS_SESSION_CREATION:
298 log.debug("Session ID and/or session token is missing.")
299 candidate = None
300 found = candidate is not None
301 if found:
302 candidate.last_activity_utc = now
303 if DEBUG_CAMCOPS_SESSION_CREATION:
304 log.debug("Committing for last_activity_utc")
305 dbsession.commit() # avoid holding a lock, 2019-03-21
306 ccsession = candidate
307 else:
308 new_http_session = cls(ip_addr=ip_addr, last_activity_utc=now)
309 dbsession.add(new_http_session)
310 if DEBUG_CAMCOPS_SESSION_CREATION:
311 log.debug("Creating new CamcopsSession: {!r}",
312 new_http_session)
313 # But we DO NOT FLUSH and we DO NOT SET THE COOKIES YET, because
314 # we might hot-swap the session.
315 # See complete_request_add_cookies().
316 ccsession = new_http_session
317 return ccsession
319 @classmethod
320 def get_oldest_last_activity_allowed(
321 cls, req: "CamcopsRequest") -> Pendulum:
322 """
323 What is the latest time that the last activity (for a session) could
324 have occurred, before the session would have timed out?
326 Calculated as ``now - session_timeout``.
327 """
328 cfg = req.config
329 now = req.now_utc
330 oldest_last_activity_allowed = now - cfg.session_timeout
331 return oldest_last_activity_allowed
333 @classmethod
334 def delete_old_sessions(cls, req: "CamcopsRequest") -> None:
335 """
336 Delete all expired sessions.
337 """
338 oldest_last_activity_allowed = \
339 cls.get_oldest_last_activity_allowed(req)
340 dbsession = req.dbsession
341 log.debug("Deleting expired sessions")
342 dbsession.query(cls)\
343 .filter(cls.last_activity_utc < oldest_last_activity_allowed)\
344 .delete(synchronize_session=False)
345 # 2020-09-22: The cascade-delete to TaskFilter (see above) isn't
346 # working, even without synchronize_session=False, and even after
347 # adding delete-orphan and single_parent=True. So:
348 subquery_active_taskfilter_ids = (
349 dbsession.query(cls.task_filter_id)
350 )
351 dbsession.query(TaskFilter)\
352 .filter(TaskFilter.id.notin_(subquery_active_taskfilter_ids))\
353 .delete(synchronize_session=False)
355 @classmethod
356 def n_sessions_active_since(cls, req: "CamcopsRequest",
357 when: Pendulum) -> int:
358 when_utc = pendulum_to_utc_datetime_without_tz(when)
359 q = (
360 CountStarSpecializedQuery(cls, session=req.dbsession)
361 .filter(cls.last_activity_utc >= when_utc)
362 )
363 return q.count_star()
365 def __init__(self,
366 ip_addr: str = None,
367 last_activity_utc: Pendulum = None):
368 """
369 Args:
370 ip_addr: client IP address
371 last_activity_utc: date/time of last activity that occurred
372 """
373 self.token = generate_token()
374 self.ip_address = ip_addr
375 self.last_activity_utc = last_activity_utc
377 # -------------------------------------------------------------------------
378 # User info and login/logout
379 # -------------------------------------------------------------------------
381 @property
382 def username(self) -> Optional[str]:
383 """
384 Returns the user's username, or ``None``.
385 """
386 if self.user:
387 return self.user.username
388 return None
390 def logout(self) -> None:
391 """
392 Log out, wiping session details.
393 """
394 self.user_id = None
395 self.token = '' # so there's no way this token can be re-used
397 def login(self, user: User) -> None:
398 """
399 Log in. Associates the user with the session and makes a new
400 token.
402 2021-05-01: If this is an API session, we don't interfere with other
403 sessions. But if it is a human logging in, we log out any other non-API
404 sessions from the same user (per security recommendations: one session
405 per authenticated user -- with exceptions that we make for API
406 sessions).
407 """
408 if DEBUG_CAMCOPS_SESSION_CREATION:
409 log.debug("Session {} login: username={!r}",
410 self.id, user.username)
411 self.user = user # will set our user_id FK
412 self.token = generate_token()
413 # fresh token: https://www.owasp.org/index.php/Session_fixation
415 if not self.is_api_session:
416 # Log out any other sessions from the same user.
417 # NOTE that "self" may not have been flushed to the database yet,
418 # so self.id may be None.
419 dbsession = SqlASession.object_session(self)
420 assert dbsession, "No dbsession for a logged-in CamcopsSession"
421 query = (
422 dbsession.query(CamcopsSession)
423 .filter(CamcopsSession.user_id == user.id)
424 # ... "same user"
425 .filter(CamcopsSession.is_api_session == False) # noqa: E712
426 # ... "human webviewer sessions"
427 .filter(CamcopsSession.id != self.id)
428 # ... "not this session".
429 # If we have an ID, this will find sessions with a different
430 # ID. If we don't have an ID, that will equate to
431 # "CamcopsSession.id != None", which will translate in SQL to
432 # "id IS NOT NULL", as per
433 # https://docs.sqlalchemy.org/en/14/core/sqlelement.html#sqlalchemy.sql.expression.ColumnElement.__ne__ # noqa
434 )
435 query.delete(synchronize_session=False)
437 # -------------------------------------------------------------------------
438 # Filters
439 # -------------------------------------------------------------------------
441 def get_task_filter(self) -> TaskFilter:
442 """
443 Returns the :class:`camcops_server.cc_modules.cc_taskfilter.TaskFilter`
444 in use for this session.
445 """
446 if not self.task_filter:
447 dbsession = SqlASession.object_session(self)
448 assert dbsession, (
449 "CamcopsSession.get_task_filter() called on a CamcopsSession "
450 "that's not yet in a database session")
451 self.task_filter = TaskFilter()
452 dbsession.add(self.task_filter)
453 return self.task_filter