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

1""" 

2camcops_server/cc_modules/cc_session.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26**Implements sessions for web clients (humans).** 

27 

28""" 

29 

30import datetime 

31import logging 

32from typing import Any, Optional, TYPE_CHECKING 

33 

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 

51 

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 

62 

63if TYPE_CHECKING: 

64 from camcops_server.cc_modules.cc_request import CamcopsRequest 

65 from camcops_server.cc_modules.cc_tabletsession import TabletSession 

66 

67log = BraceStyleAdapter(logging.getLogger(__name__)) 

68 

69 

70# ============================================================================= 

71# Debugging options 

72# ============================================================================= 

73 

74DEBUG_CAMCOPS_SESSION_CREATION = False 

75 

76if DEBUG_CAMCOPS_SESSION_CREATION: 

77 log.warning("Debugging options enabled!") 

78 

79 

80# ============================================================================= 

81# Constants 

82# ============================================================================= 

83 

84DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25 

85 

86 

87# ============================================================================= 

88# Security for web sessions 

89# ============================================================================= 

90 

91 

92def generate_token(num_bytes: int = 16) -> str: 

93 """ 

94 Make a new session token that's not in use. 

95 

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) 

102 

103 

104# ============================================================================= 

105# Session class 

106# ============================================================================= 

107 

108 

109class CamcopsSession(Base): 

110 """ 

111 Class representing an HTTPS session. 

112 """ 

113 

114 __tablename__ = "_security_webviewer_sessions" 

115 

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 

171 

172 # ------------------------------------------------------------------------- 

173 # Basic info 

174 # ------------------------------------------------------------------------- 

175 

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 ) 

189 

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) 

197 

198 # ------------------------------------------------------------------------- 

199 # Creating sessions 

200 # ------------------------------------------------------------------------- 

201 

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. 

210 

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) 

220 

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`. 

228 

229 This also performs user authorization. 

230 

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 

253 

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) 

280 

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 

310 

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 ) 

322 

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) 

328 

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 

356 

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? 

364 

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 

371 

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) 

392 

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() 

402 

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 

418 

419 # ------------------------------------------------------------------------- 

420 # User info and login/logout 

421 # ------------------------------------------------------------------------- 

422 

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 

431 

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 

438 

439 def login(self, user: User) -> None: 

440 """ 

441 Log in. Associates the user with the session and makes a new 

442 token. 

443 

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 

457 

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) 

480 

481 # ------------------------------------------------------------------------- 

482 # Filters 

483 # ------------------------------------------------------------------------- 

484 

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