Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/cc_modules/cc_session.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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

24 

25=============================================================================== 

26 

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

28 

29""" 

30 

31import logging 

32from typing import 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 relationship, Session as SqlASession 

45from sqlalchemy.sql.schema import Column, ForeignKey 

46from sqlalchemy.sql.sqltypes import Boolean, DateTime, Integer 

47 

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) 

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("cc_session: Debugging options enabled!") 

75 

76# ============================================================================= 

77# Constants 

78# ============================================================================= 

79 

80DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25 

81 

82 

83# ============================================================================= 

84# Security for web sessions 

85# ============================================================================= 

86 

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

88 """ 

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

90 

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) 

97 

98 

99# ============================================================================= 

100# Session class 

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

102 

103class CamcopsSession(Base): 

104 """ 

105 Class representing an HTTPS session. 

106 """ 

107 __tablename__ = "_security_webviewer_sessions" 

108 

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 ) 

147 

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 

160 

161 # ------------------------------------------------------------------------- 

162 # Basic info 

163 # ------------------------------------------------------------------------- 

164 

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 ) 

172 

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) 

180 

181 # ------------------------------------------------------------------------- 

182 # Creating sessions 

183 # ------------------------------------------------------------------------- 

184 

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. 

192 

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) 

202 

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

210 

211 This also performs user authorization. 

212 

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 

232 

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) 

256 

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 

280 

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 

318 

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? 

325 

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 

332 

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) 

354 

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

364 

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 

376 

377 # ------------------------------------------------------------------------- 

378 # User info and login/logout 

379 # ------------------------------------------------------------------------- 

380 

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 

389 

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 

396 

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

398 """ 

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

400 token. 

401 

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 

414 

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) 

436 

437 # ------------------------------------------------------------------------- 

438 # Filters 

439 # ------------------------------------------------------------------------- 

440 

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