Coverage for cc_modules/cc_session.py: 56%

1"""

2 camcops_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

30 import datetime

31 import logging

32 from typing import Any, Optional, TYPE_CHECKING

33

34 from cardinal_pythonlib.datetimefunc import (

35 format_datetime,

36 pendulum_to_utc_datetime_without_tz,

37)

38 from cardinal_pythonlib.reprfunc import simple_repr

39 from cardinal_pythonlib.logs import BraceStyleAdapter

40 from cardinal_pythonlib.randomness import create_base64encoded_randomness

41 from cardinal_pythonlib.sqlalchemy.orm_query import CountStarSpecializedQuery

42 from pendulum import DateTime as Pendulum

43 from pyramid.interfaces import ISession

44 from sqlalchemy.orm import (

45 Mapped,

46 mapped_column,

47 relationship,

48 Session as SqlASession,

49)

50 from sqlalchemy.sql.schema import ForeignKey

51

52 from camcops_server.cc_modules.cc_constants import DateFormat

53 from camcops_server.cc_modules.cc_pyramid import CookieKey

54 from camcops_server.cc_modules.cc_sqla_coltypes import (

55 IPAddressColType,

56 JsonColType,

57 SessionTokenColType,

58)

59 from camcops_server.cc_modules.cc_sqlalchemy import Base, MutableDict

60 from camcops_server.cc_modules.cc_taskfilter import TaskFilter

61 from camcops_server.cc_modules.cc_user import User

62

63 if TYPE_CHECKING:

64 from camcops_server.cc_modules.cc_request import CamcopsRequest

65 from camcops_server.cc_modules.cc_tabletsession import TabletSession

66

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

68

69

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

71# Debugging options

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

73

74 DEBUG_CAMCOPS_SESSION_CREATION = False

75

76 if DEBUG_CAMCOPS_SESSION_CREATION:

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

78

79

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

81# Constants

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

83

84 DEFAULT_NUMBER_OF_TASKS_TO_VIEW = 25

85

86

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

88# Security for web sessions

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

90

91

92 def 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

109 class 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