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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_session.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

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

29 

30""" 

31 

32import logging 

33from typing import Optional, TYPE_CHECKING 

34 

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 

48 

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 

59 

60if TYPE_CHECKING: 

61 from camcops_server.cc_modules.cc_request import CamcopsRequest 

62 from camcops_server.cc_modules.cc_tabletsession import TabletSession 

63 

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

65 

66 

67# ============================================================================= 

68# Debugging options 

69# ============================================================================= 

70 

71DEBUG_CAMCOPS_SESSION_CREATION = False 

72 

73if DEBUG_CAMCOPS_SESSION_CREATION: 

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

75 

76 

77# ============================================================================= 

78# Constants 

79# ============================================================================= 

80 

81DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25 

82 

83 

84# ============================================================================= 

85# Security for web sessions 

86# ============================================================================= 

87 

88 

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

90 """ 

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

92 

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) 

99 

100 

101# ============================================================================= 

102# Session class 

103# ============================================================================= 

104 

105 

106class CamcopsSession(Base): 

107 """ 

108 Class representing an HTTPS session. 

109 """ 

110 

111 __tablename__ = "_security_webviewer_sessions" 

112 

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 

179 

180 # ------------------------------------------------------------------------- 

181 # Basic info 

182 # ------------------------------------------------------------------------- 

183 

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 ) 

197 

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) 

205 

206 # ------------------------------------------------------------------------- 

207 # Creating sessions 

208 # ------------------------------------------------------------------------- 

209 

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. 

218 

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) 

228 

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

236 

237 This also performs user authorization. 

238 

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 

261 

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) 

288 

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 

318 

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 ) 

330 

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) 

336 

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 

364 

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? 

372 

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 

379 

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) 

400 

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

410 

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 

422 

423 # ------------------------------------------------------------------------- 

424 # User info and login/logout 

425 # ------------------------------------------------------------------------- 

426 

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 

435 

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 

442 

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

444 """ 

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

446 token. 

447 

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 

461 

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) 

484 

485 # ------------------------------------------------------------------------- 

486 # Filters 

487 # ------------------------------------------------------------------------- 

488 

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