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