Coverage for cc_modules/cc_session.py: 56%
160 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
1"""
2camcops_server/cc_modules/cc_session.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26**Implements sessions for web clients (humans).**
28"""
30import datetime
31import logging
32from typing import Any, 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 (
45 Mapped,
46 mapped_column,
47 relationship,
48 Session as SqlASession,
49)
50from sqlalchemy.sql.schema import ForeignKey
52from camcops_server.cc_modules.cc_constants import DateFormat
53from camcops_server.cc_modules.cc_pyramid import CookieKey
54from camcops_server.cc_modules.cc_sqla_coltypes import (
55 IPAddressColType,
56 JsonColType,
57 SessionTokenColType,
58)
59from camcops_server.cc_modules.cc_sqlalchemy import Base, MutableDict
60from camcops_server.cc_modules.cc_taskfilter import TaskFilter
61from camcops_server.cc_modules.cc_user import User
63if TYPE_CHECKING:
64 from camcops_server.cc_modules.cc_request import CamcopsRequest
65 from camcops_server.cc_modules.cc_tabletsession import TabletSession
67log = BraceStyleAdapter(logging.getLogger(__name__))
70# =============================================================================
71# Debugging options
72# =============================================================================
74DEBUG_CAMCOPS_SESSION_CREATION = False
76if DEBUG_CAMCOPS_SESSION_CREATION:
77 log.warning("Debugging options enabled!")
80# =============================================================================
81# Constants
82# =============================================================================
84DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25
87# =============================================================================
88# Security for web sessions
89# =============================================================================
92def generate_token(num_bytes: int = 16) -> str:
93 """
94 Make a new session token that's not in use.
96 It doesn't matter if it's already in use by a session with a different ID,
97 because the ID/token pair is unique. (Removing that constraint gets rid of
98 an in-principle-but-rare locking problem.)
99 """
100 # http://stackoverflow.com/questions/817882/unique-session-id-in-python
101 return create_base64encoded_randomness(num_bytes)
104# =============================================================================
105# Session class
106# =============================================================================
109class CamcopsSession(Base):
110 """
111 Class representing an HTTPS session.
112 """
114 __tablename__ = "_security_webviewer_sessions"
116 # no TEXT fields here; this is a performance-critical table
117 id: Mapped[int] = mapped_column(
118 primary_key=True,
119 autoincrement=True,
120 index=True,
121 comment="Session ID (internal number for insertion speed)",
122 )
123 token: Mapped[Optional[str]] = mapped_column(
124 SessionTokenColType,
125 comment="Token (base 64 encoded random number)",
126 )
127 ip_address: Mapped[Optional[str]] = mapped_column(
128 IPAddressColType, comment="IP address of user"
129 )
130 user_id: Mapped[Optional[int]] = mapped_column(
131 ForeignKey("_security_users.id", ondelete="CASCADE"),
132 # https://docs.sqlalchemy.org/en/latest/core/constraints.html#on-update-and-on-delete # noqa
133 comment="User ID",
134 )
135 last_activity_utc: Mapped[Optional[datetime.datetime]] = mapped_column(
136 comment="Date/time of last activity (UTC)",
137 )
138 number_to_view: Mapped[Optional[int]] = mapped_column(
139 comment="Number of records to view"
140 )
141 task_filter_id: Mapped[Optional[int]] = mapped_column(
142 ForeignKey("_task_filters.id"),
143 comment="Task filter ID",
144 )
145 is_api_session: Mapped[Optional[bool]] = mapped_column(
146 default=False,
147 comment="This session is using the client API (not a human browsing).",
148 )
149 form_state: Mapped[Optional[Any]] = mapped_column(
150 "form_state",
151 MutableDict.as_mutable(JsonColType),
152 comment=(
153 "Any state that needs to be saved temporarily during "
154 "wizard-style form submission"
155 ),
156 )
157 user = relationship("User", lazy="joined", foreign_keys=[user_id])
158 task_filter = relationship(
159 "TaskFilter",
160 foreign_keys=[task_filter_id],
161 cascade="all, delete-orphan",
162 single_parent=True,
163 )
164 # ... "save-update, merge" is the default. We are adding "delete", which
165 # means that when this CamcopsSession is deleted, the corresponding
166 # TaskFilter will be deleted as well. See
167 # https://docs.sqlalchemy.org/en/latest/orm/cascades.html#delete
168 # ... 2020-09-22: changed to "all, delete-orphan" and single_parent=True
169 # https://docs.sqlalchemy.org/en/13/orm/cascades.html#cascade-delete-orphan
170 # https://docs.sqlalchemy.org/en/13/errors.html#error-bbf0
172 # -------------------------------------------------------------------------
173 # Basic info
174 # -------------------------------------------------------------------------
176 def __repr__(self) -> str:
177 return simple_repr(
178 self,
179 [
180 "id",
181 "token",
182 "ip_address",
183 "user_id",
184 "last_activity_utc_iso",
185 "user",
186 ],
187 with_addr=True,
188 )
190 @property
191 def last_activity_utc_iso(self) -> str:
192 """
193 Returns a formatted version of the date/time at which the last
194 activity took place for this session.
195 """
196 return format_datetime(self.last_activity_utc, DateFormat.ISO8601)
198 # -------------------------------------------------------------------------
199 # Creating sessions
200 # -------------------------------------------------------------------------
202 @classmethod
203 def get_session_using_cookies(
204 cls, req: "CamcopsRequest"
205 ) -> "CamcopsSession":
206 """
207 Makes, or retrieves, a new
208 :class:`camcops_server.cc_modules.cc_session.CamcopsSession` for this
209 Pyramid Request.
211 The session is found using the ID/token information in the request's
212 cookies.
213 """
214 pyramid_session = req.session # type: ISession
215 # noinspection PyArgumentList
216 session_id_str = pyramid_session.get(CookieKey.SESSION_ID, "")
217 # noinspection PyArgumentList
218 session_token = pyramid_session.get(CookieKey.SESSION_TOKEN, "")
219 return cls.get_session(req, session_id_str, session_token)
221 @classmethod
222 def get_session_for_tablet(cls, ts: "TabletSession") -> "CamcopsSession":
223 """
224 For a given
225 :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession` (used
226 by tablet client devices), returns a corresponding
227 :class:`camcops_server.cc_modules.cc_session.CamcopsSession`.
229 This also performs user authorization.
231 User authentication is via the
232 :class:`camcops_server.cc_modules.cc_session.CamcopsSession`.
233 """
234 session = cls.get_session(
235 req=ts.req,
236 session_id_str=ts.session_id, # type: ignore[arg-type]
237 session_token=ts.session_token,
238 )
239 if not session.user:
240 session._login_from_ts(ts)
241 elif session.user and session.user.username != ts.username:
242 # We found a session, and it's associated with a user, but with
243 # the wrong user. This is unlikely to happen!
244 # Wipe the old one:
245 req = ts.req
246 session.logout()
247 # Create a fresh session.
248 session = cls.get_session(
249 req=req, session_id_str=None, session_token=None
250 )
251 session._login_from_ts(ts)
252 return session
254 def _login_from_ts(self, ts: "TabletSession") -> None:
255 """
256 Used by :meth:`get_session_for_tablet` to log in using information
257 provided by a
258 :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession`.
259 """
260 if DEBUG_CAMCOPS_SESSION_CREATION:
261 log.debug(
262 "Considering login from tablet (with username: {!r}",
263 ts.username,
264 )
265 self.is_api_session = True
266 if ts.username:
267 user = User.get_user_from_username_password(
268 ts.req, ts.username, ts.password
269 )
270 if DEBUG_CAMCOPS_SESSION_CREATION:
271 log.debug("... looked up User: {!r}", user)
272 if user:
273 # Successful login of sorts, ALTHOUGH the user may be
274 # severely restricted (if they can neither register nor
275 # upload). However, effecting a "login" here means that the
276 # error messages can become more helpful!
277 self.login(user)
278 if DEBUG_CAMCOPS_SESSION_CREATION:
279 log.debug("... final session user: {!r}", self.user)
281 @classmethod
282 def get_session(
283 cls,
284 req: "CamcopsRequest",
285 session_id_str: Optional[str],
286 session_token: Optional[str],
287 ) -> "CamcopsSession":
288 """
289 Retrieves, or makes, a new
290 :class:`camcops_server.cc_modules.cc_session.CamcopsSession` for this
291 Pyramid Request, given a specific ``session_id`` and ``session_token``.
292 """
293 if DEBUG_CAMCOPS_SESSION_CREATION:
294 log.debug(
295 "CamcopsSession.get_session: session_id_str={!r}, "
296 "session_token={!r}",
297 session_id_str,
298 session_token,
299 )
300 # ---------------------------------------------------------------------
301 # Starting variables
302 # ---------------------------------------------------------------------
303 try:
304 session_id = int(session_id_str)
305 except (TypeError, ValueError):
306 session_id = None
307 dbsession = req.dbsession
308 ip_addr = req.remote_addr
309 now = req.now_utc
311 # ---------------------------------------------------------------------
312 # Fetch or create
313 # ---------------------------------------------------------------------
314 if session_id and session_token:
315 oldest_permitted = cls.get_oldest_last_activity_allowed(req)
316 query = (
317 dbsession.query(cls)
318 .filter(cls.id == session_id)
319 .filter(cls.token == session_token)
320 .filter(cls.last_activity_utc >= oldest_permitted)
321 )
323 if req.config.session_check_user_ip:
324 # Binding the session to the IP address can cause problems if
325 # the IP address changes before the session times out. A load
326 # balancer may cause this.
327 query = query.filter(cls.ip_address == ip_addr)
329 candidate = query.first() # type: Optional[CamcopsSession]
330 if DEBUG_CAMCOPS_SESSION_CREATION:
331 if candidate is None:
332 log.debug("Session not found in database")
333 else:
334 if DEBUG_CAMCOPS_SESSION_CREATION:
335 log.debug("Session ID and/or session token is missing.")
336 candidate = None
337 found = candidate is not None
338 if found:
339 candidate.last_activity_utc = now
340 if DEBUG_CAMCOPS_SESSION_CREATION:
341 log.debug("Committing for last_activity_utc")
342 dbsession.commit() # avoid holding a lock, 2019-03-21
343 ccsession = candidate
344 else:
345 new_http_session = cls(ip_addr=ip_addr, last_activity_utc=now)
346 dbsession.add(new_http_session)
347 if DEBUG_CAMCOPS_SESSION_CREATION:
348 log.debug(
349 "Creating new CamcopsSession: {!r}", new_http_session
350 )
351 # But we DO NOT FLUSH and we DO NOT SET THE COOKIES YET, because
352 # we might hot-swap the session.
353 # See complete_request_add_cookies().
354 ccsession = new_http_session
355 return ccsession
357 @classmethod
358 def get_oldest_last_activity_allowed(
359 cls, req: "CamcopsRequest"
360 ) -> Pendulum:
361 """
362 What is the latest time that the last activity (for a session) could
363 have occurred, before the session would have timed out?
365 Calculated as ``now - session_timeout``.
366 """
367 cfg = req.config
368 now = req.now_utc
369 oldest_last_activity_allowed = now - cfg.session_timeout
370 return oldest_last_activity_allowed
372 @classmethod
373 def delete_old_sessions(cls, req: "CamcopsRequest") -> None:
374 """
375 Delete all expired sessions.
376 """
377 oldest_last_activity_allowed = cls.get_oldest_last_activity_allowed(
378 req
379 )
380 dbsession = req.dbsession
381 log.debug("Deleting expired sessions")
382 dbsession.query(cls).filter(
383 cls.last_activity_utc < oldest_last_activity_allowed
384 ).delete(synchronize_session=False)
385 # 2020-09-22: The cascade-delete to TaskFilter (see above) isn't
386 # working, even without synchronize_session=False, and even after
387 # adding delete-orphan and single_parent=True. So:
388 subquery_active_taskfilter_ids = dbsession.query(cls.task_filter_id)
389 dbsession.query(TaskFilter).filter(
390 TaskFilter.id.notin_(subquery_active_taskfilter_ids)
391 ).delete(synchronize_session=False)
393 @classmethod
394 def n_sessions_active_since(
395 cls, req: "CamcopsRequest", when: Pendulum
396 ) -> int:
397 when_utc = pendulum_to_utc_datetime_without_tz(when)
398 q = CountStarSpecializedQuery(cls, session=req.dbsession).filter( # type: ignore[arg-type] # noqa: E501
399 cls.last_activity_utc >= when_utc
400 )
401 return q.count_star()
403 def __init__(
404 self,
405 ip_addr: str = None,
406 last_activity_utc: Pendulum = None,
407 **kwargs: Any
408 ):
409 """
410 Args:
411 ip_addr: client IP address
412 last_activity_utc: date/time of last activity that occurred
413 """
414 super().__init__(**kwargs)
415 self.token = generate_token()
416 self.ip_address = ip_addr
417 self.last_activity_utc = last_activity_utc
419 # -------------------------------------------------------------------------
420 # User info and login/logout
421 # -------------------------------------------------------------------------
423 @property
424 def username(self) -> Optional[str]:
425 """
426 Returns the user's username, or ``None``.
427 """
428 if self.user:
429 return self.user.username
430 return None
432 def logout(self) -> None:
433 """
434 Log out, wiping session details.
435 """
436 self.user_id = None
437 self.token = "" # so there's no way this token can be re-used
439 def login(self, user: User) -> None:
440 """
441 Log in. Associates the user with the session and makes a new
442 token.
444 2021-05-01: If this is an API session, we don't interfere with other
445 sessions. But if it is a human logging in, we log out any other non-API
446 sessions from the same user (per security recommendations: one session
447 per authenticated user -- with exceptions that we make for API
448 sessions).
449 """
450 if DEBUG_CAMCOPS_SESSION_CREATION:
451 log.debug(
452 "Session {} login: username={!r}", self.id, user.username
453 )
454 self.user = user # will set our user_id FK
455 self.token = generate_token()
456 # fresh token: https://www.owasp.org/index.php/Session_fixation
458 if not self.is_api_session:
459 # Log out any other sessions from the same user.
460 # NOTE that "self" may not have been flushed to the database yet,
461 # so self.id may be None.
462 dbsession = SqlASession.object_session(self)
463 assert dbsession, "No dbsession for a logged-in CamcopsSession"
464 query = (
465 dbsession.query(CamcopsSession).filter(
466 CamcopsSession.user_id == user.id
467 )
468 # ... "same user"
469 .filter(CamcopsSession.is_api_session == False) # noqa: E712
470 # ... "human webviewer sessions"
471 .filter(CamcopsSession.id != self.id)
472 # ... "not this session".
473 # If we have an ID, this will find sessions with a different
474 # ID. If we don't have an ID, that will equate to
475 # "CamcopsSession.id != None", which will translate in SQL to
476 # "id IS NOT NULL", as per
477 # https://docs.sqlalchemy.org/en/14/core/sqlelement.html#sqlalchemy.sql.expression.ColumnElement.__ne__ # noqa
478 )
479 query.delete(synchronize_session=False)
481 # -------------------------------------------------------------------------
482 # Filters
483 # -------------------------------------------------------------------------
485 def get_task_filter(self) -> TaskFilter:
486 """
487 Returns the :class:`camcops_server.cc_modules.cc_taskfilter.TaskFilter`
488 in use for this session.
489 """
490 if not self.task_filter:
491 dbsession = SqlASession.object_session(self)
492 assert dbsession, (
493 "CamcopsSession.get_task_filter() called on a CamcopsSession "
494 "that's not yet in a database session"
495 )
496 self.task_filter = TaskFilter()
497 dbsession.add(self.task_filter)
498 return self.task_filter