Coverage for cc_modules/cc_user.py: 42%

528 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_user.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**CamCOPS users.** 

29 

30""" 

31 

32import datetime 

33import logging 

34import re 

35from typing import List, Optional, Set, Tuple, TYPE_CHECKING 

36 

37import cardinal_pythonlib.crypto as rnc_crypto 

38from cardinal_pythonlib.datetimefunc import convert_datetime_to_local 

39from cardinal_pythonlib.logs import BraceStyleAdapter 

40from cardinal_pythonlib.reprfunc import simple_repr 

41from cardinal_pythonlib.sqlalchemy.orm_query import ( 

42 CountStarSpecializedQuery, 

43 exists_orm, 

44) 

45from pendulum import DateTime as Pendulum 

46import phonenumbers 

47import pyotp 

48from sqlalchemy import text 

49from sqlalchemy.ext.associationproxy import association_proxy 

50from sqlalchemy.orm import relationship, Session as SqlASession, Query 

51from sqlalchemy.sql import false 

52from sqlalchemy.sql.expression import and_, exists, not_ 

53from sqlalchemy.sql.functions import func 

54from sqlalchemy.sql.schema import Column, ForeignKey 

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

56 

57from camcops_server.cc_modules.cc_audit import audit 

58from camcops_server.cc_modules.cc_constants import ( 

59 MfaMethod, 

60 OBSCURE_EMAIL_ASTERISKS, 

61 OBSCURE_PHONE_ASTERISKS, 

62 USER_NAME_FOR_SYSTEM, 

63) 

64from camcops_server.cc_modules.cc_group import Group 

65from camcops_server.cc_modules.cc_membership import UserGroupMembership 

66from camcops_server.cc_modules.cc_sqla_coltypes import ( 

67 Base32ColType, 

68 EmailAddressColType, 

69 FullNameColType, 

70 HashedPasswordColType, 

71 LanguageCodeColType, 

72 MfaMethodColType, 

73 PendulumDateTimeAsIsoTextColType, 

74 PhoneNumberColType, 

75 UserNameCamcopsColType, 

76) 

77from camcops_server.cc_modules.cc_sqlalchemy import Base 

78from camcops_server.cc_modules.cc_text import TERMS_CONDITIONS_UPDATE_DATE 

79 

80if TYPE_CHECKING: 

81 from camcops_server.cc_modules.cc_patient import Patient 

82 from camcops_server.cc_modules.cc_request import CamcopsRequest 

83 

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

85 

86 

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

88# Constants 

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

90 

91_TYPE_LUGM = List[UserGroupMembership] 

92 

93VALID_USERNAME_REGEX = "^[A-Za-z0-9_-]+$" 

94BCRYPT_DEFAULT_LOG_ROUNDS = 6 

95# Default is 12, but it does impact on the tablet upload speed (cost per 

96# transaction). Time is expected to be proportional to 2^n, i.e. incrementing 1 

97# increases time by a factor of 2. 

98# Empirically, on egret: 

99# 2^12 rounds takes around 400 ms 

100# 2^8 rounds takes around 30 ms (as expected, 1/16 of the time as for 12) 

101# we'd like around 8 ms; http://security.stackexchange.com/questions/17207 

102# ... so we should be using 12 + log(8/400)/log(2) = 6 rounds 

103 

104CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS = 7 

105CLEAR_DUMMY_LOGIN_PERIOD = datetime.timedelta( 

106 days=CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS 

107) 

108 

109 

110# ============================================================================= 

111# SecurityAccountLockout 

112# ============================================================================= 

113# Note that we record login failures for non-existent users, and pretend 

114# they're locked out (to prevent username discovery that way, by timing) 

115 

116 

117class SecurityAccountLockout(Base): 

118 """ 

119 Represents an account "lockout". 

120 """ 

121 

122 __tablename__ = "_security_account_lockouts" 

123 

124 id = Column("id", Integer, primary_key=True, autoincrement=True) 

125 username = Column( 

126 "username", 

127 UserNameCamcopsColType, 

128 nullable=False, 

129 index=True, 

130 comment="User name (which may be a non-existent user, to prevent " 

131 "subtle username discovery by careful timing)", 

132 ) 

133 locked_until = Column( 

134 "locked_until", 

135 DateTime, 

136 nullable=False, 

137 index=True, 

138 comment="Account is locked until (UTC)", 

139 ) 

140 

141 @classmethod 

142 def delete_old_account_lockouts(cls, req: "CamcopsRequest") -> None: 

143 """ 

144 Delete all expired account lockouts. 

145 """ 

146 dbsession = req.dbsession 

147 now = req.now_utc 

148 dbsession.query(cls).filter(cls.locked_until <= now).delete( 

149 synchronize_session=False 

150 ) 

151 

152 @classmethod 

153 def is_user_locked_out(cls, req: "CamcopsRequest", username: str) -> bool: 

154 """ 

155 Is the specified user locked out? 

156 

157 Args: 

158 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

159 username: the user's username 

160 """ 

161 dbsession = req.dbsession 

162 now = req.now_utc 

163 return exists_orm( 

164 dbsession, cls, cls.username == username, cls.locked_until > now 

165 ) 

166 

167 @classmethod 

168 def user_locked_out_until( 

169 cls, req: "CamcopsRequest", username: str 

170 ) -> Optional[Pendulum]: 

171 """ 

172 When is the user locked out until? 

173 

174 Args: 

175 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

176 username: the user's username 

177 

178 Returns: 

179 Pendulum datetime in local timezone (or ``None`` if not 

180 locked out). 

181 """ 

182 dbsession = req.dbsession 

183 now = req.now_utc 

184 locked_until_utc = ( 

185 dbsession.query(func.max(cls.locked_until)) 

186 .filter(cls.username == username) 

187 .filter(cls.locked_until > now) 

188 .scalar() 

189 ) # type: Optional[Pendulum] 

190 # ... NOT first(), which returns (result,); we want just result 

191 if not locked_until_utc: 

192 return None 

193 return convert_datetime_to_local(locked_until_utc) 

194 

195 @classmethod 

196 def lock_user_out( 

197 cls, req: "CamcopsRequest", username: str, lockout_minutes: int 

198 ) -> None: 

199 """ 

200 Lock user out for a specified number of minutes. 

201 

202 Args: 

203 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

204 username: the user's username 

205 lockout_minutes: number of minutes 

206 """ 

207 dbsession = req.dbsession 

208 now = req.now_utc 

209 lock_until = now + datetime.timedelta(minutes=lockout_minutes) 

210 # noinspection PyArgumentList 

211 lock = cls(username=username, locked_until=lock_until) 

212 dbsession.add(lock) 

213 audit( 

214 req, f"Account {username} locked out for {lockout_minutes} minutes" 

215 ) 

216 

217 @classmethod 

218 def unlock_user(cls, req: "CamcopsRequest", username: str) -> None: 

219 """ 

220 Unlock a user. 

221 

222 Args: 

223 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

224 username: the user's username 

225 """ 

226 dbsession = req.dbsession 

227 dbsession.query(cls).filter(cls.username == username).delete( 

228 synchronize_session=False 

229 ) 

230 

231 

232# ============================================================================= 

233# SecurityLoginFailure 

234# ============================================================================= 

235 

236 

237class SecurityLoginFailure(Base): 

238 """ 

239 Represents a record of a failed login. 

240 

241 Too many failed logins lead to a lockout; see 

242 :class:`SecurityAccountLockout`. 

243 """ 

244 

245 __tablename__ = "_security_login_failures" 

246 

247 id = Column("id", Integer, primary_key=True, autoincrement=True) 

248 username = Column( 

249 "username", 

250 UserNameCamcopsColType, 

251 nullable=False, 

252 index=True, 

253 comment="User name (which may be a non-existent user, to prevent " 

254 "subtle username discovery by careful timing)", 

255 ) 

256 login_failure_at = Column( 

257 "login_failure_at", 

258 DateTime, 

259 nullable=False, 

260 index=True, 

261 comment="Login failure occurred at (UTC)", 

262 ) 

263 

264 @classmethod 

265 def record_login_failure( 

266 cls, req: "CamcopsRequest", username: str 

267 ) -> None: 

268 """ 

269 Record that a user has failed to log in. 

270 

271 Args: 

272 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

273 username: the user's username 

274 """ 

275 dbsession = req.dbsession 

276 now = req.now_utc 

277 # noinspection PyArgumentList 

278 failure = cls(username=username, login_failure_at=now) 

279 dbsession.add(failure) 

280 

281 @classmethod 

282 def act_on_login_failure( 

283 cls, req: "CamcopsRequest", username: str 

284 ) -> None: 

285 """ 

286 Record login failure and lock out user if necessary. 

287 

288 Args: 

289 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

290 username: the user's username 

291 """ 

292 cfg = req.config 

293 audit(req, f"Failed login as user: {username}") 

294 cls.record_login_failure(req, username) 

295 nfailures = cls.how_many_login_failures(req, username) 

296 nlockouts = nfailures // cfg.lockout_threshold 

297 nfailures_since_last_lockout = nfailures % cfg.lockout_threshold 

298 if nlockouts >= 1 and nfailures_since_last_lockout == 0: 

299 # new lockout required 

300 lockout_minutes = ( 

301 nlockouts * cfg.lockout_duration_increment_minutes 

302 ) 

303 SecurityAccountLockout.lock_user_out( 

304 req, username, lockout_minutes 

305 ) 

306 

307 @classmethod 

308 def clear_login_failures( 

309 cls, req: "CamcopsRequest", username: str 

310 ) -> None: 

311 """ 

312 Clear login failures for a user. 

313 

314 Args: 

315 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

316 username: the user's username 

317 """ 

318 dbsession = req.dbsession 

319 dbsession.query(cls).filter(cls.username == username).delete( 

320 synchronize_session=False 

321 ) 

322 

323 @classmethod 

324 def how_many_login_failures( 

325 cls, req: "CamcopsRequest", username: str 

326 ) -> int: 

327 """ 

328 How many times has the user tried and failed to log in (recently)? 

329 

330 Args: 

331 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

332 username: the user's username 

333 """ 

334 dbsession = req.dbsession 

335 q = CountStarSpecializedQuery([cls], session=dbsession).filter( 

336 cls.username == username 

337 ) 

338 return q.count_star() 

339 

340 @classmethod 

341 def enable_user(cls, req: "CamcopsRequest", username: str) -> None: 

342 """ 

343 Unlock user and clear login failures. 

344 

345 Args: 

346 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

347 username: the user's username 

348 """ 

349 SecurityAccountLockout.unlock_user(req, username) 

350 cls.clear_login_failures(req, username) 

351 audit(req, f"User {username} re-enabled") 

352 

353 @classmethod 

354 def clear_login_failures_for_nonexistent_users( 

355 cls, req: "CamcopsRequest" 

356 ) -> None: 

357 """ 

358 Clear login failures for nonexistent users. 

359 

360 Login failues are recorded for nonexistent users to mimic the lockout 

361 seen for real users, i.e. to reduce the potential for username 

362 discovery. 

363 

364 Args: 

365 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

366 """ 

367 dbsession = req.dbsession 

368 all_user_names = dbsession.query(User.username) 

369 dbsession.query(cls).filter( 

370 cls.username.notin_(all_user_names) 

371 ).delete(synchronize_session=False) 

372 # https://stackoverflow.com/questions/26182027/how-to-use-not-in-clause-in-sqlalchemy-orm-query # noqa 

373 

374 @classmethod 

375 def clear_dummy_login_failures_if_necessary( 

376 cls, req: "CamcopsRequest" 

377 ) -> None: 

378 """ 

379 Clear dummy login failures if we haven't done so for a while. 

380 

381 Not too often! See :data:`CLEAR_DUMMY_LOGIN_PERIOD`. 

382 

383 Args: 

384 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

385 """ 

386 now = req.now_utc 

387 ss = req.server_settings 

388 last_dummy_login_failure_clearance = ( 

389 ss.get_last_dummy_login_failure_clearance_pendulum() 

390 ) 

391 if last_dummy_login_failure_clearance is not None: 

392 elapsed = now - last_dummy_login_failure_clearance 

393 if elapsed < CLEAR_DUMMY_LOGIN_PERIOD: 

394 # We cleared it recently. 

395 return 

396 

397 cls.clear_login_failures_for_nonexistent_users(req) 

398 log.debug("Dummy login failures cleared.") 

399 ss.last_dummy_login_failure_clearance_at_utc = now 

400 

401 

402# ============================================================================= 

403# User class 

404# ============================================================================= 

405 

406 

407class User(Base): 

408 """ 

409 Class representing a user. 

410 """ 

411 

412 __tablename__ = "_security_users" 

413 

414 # ------------------------------------------------------------------------- 

415 # Columns 

416 # ------------------------------------------------------------------------- 

417 

418 id = Column( 

419 "id", 

420 Integer, 

421 primary_key=True, 

422 autoincrement=True, 

423 index=True, 

424 comment="User ID", 

425 ) 

426 username = Column( 

427 "username", 

428 UserNameCamcopsColType, 

429 nullable=False, 

430 index=True, 

431 unique=True, 

432 comment="User name", 

433 ) # type: str 

434 fullname = Column("fullname", FullNameColType, comment="User's full name") 

435 email = Column( 

436 "email", EmailAddressColType, comment="User's e-mail address" 

437 ) 

438 phone_number = Column( 

439 "phone_number", PhoneNumberColType, comment="User's phone number" 

440 ) 

441 hashedpw = Column( 

442 "hashedpw", 

443 HashedPasswordColType, 

444 nullable=False, 

445 comment="Password hash", 

446 ) 

447 mfa_secret_key = Column( 

448 "mfa_secret_key", 

449 Base32ColType, 

450 nullable=True, 

451 comment="Secret key used for multi-factor authentication", 

452 ) 

453 mfa_method = Column( 

454 "mfa_method", 

455 MfaMethodColType, 

456 nullable=False, 

457 server_default=MfaMethod.NO_MFA, 

458 comment="Preferred method of multi-factor authentication", 

459 ) 

460 hotp_counter = Column( 

461 "hotp_counter", 

462 Integer, 

463 nullable=False, 

464 server_default=text("0"), 

465 comment="Counter used for HOTP authentication", 

466 ) 

467 last_login_at_utc = Column( 

468 "last_login_at_utc", 

469 DateTime, 

470 comment="Date/time this user last logged in (UTC)", 

471 ) 

472 last_password_change_utc = Column( 

473 "last_password_change_utc", 

474 DateTime, 

475 comment="Date/time this user last changed their password (UTC)", 

476 ) 

477 superuser = Column( 

478 "superuser", Boolean, default=False, comment="Superuser?" 

479 ) 

480 must_change_password = Column( 

481 "must_change_password", 

482 Boolean, 

483 default=False, 

484 comment="Must change password at next webview login", 

485 ) 

486 when_agreed_terms_of_use = Column( 

487 "when_agreed_terms_of_use", 

488 PendulumDateTimeAsIsoTextColType, 

489 comment="Date/time this user acknowledged the Terms and " 

490 "Conditions of Use (ISO 8601)", 

491 ) 

492 upload_group_id = Column( 

493 "upload_group_id", 

494 Integer, 

495 ForeignKey("_security_groups.id"), 

496 comment="ID of the group to which this user uploads at present", 

497 # OK to be NULL in the database, but the user will not be able to 

498 # upload while it is. 

499 ) 

500 language = Column( 

501 "language", 

502 LanguageCodeColType, 

503 comment="Language code preferred by this user", 

504 ) 

505 auto_generated = Column( 

506 "auto_generated", 

507 Boolean, 

508 nullable=False, 

509 default=False, 

510 comment="Is automatically generated user with random password", 

511 ) 

512 single_patient_pk = Column( 

513 "single_patient_pk", 

514 Integer, 

515 ForeignKey("patient._pk", ondelete="SET NULL", use_alter=True), 

516 comment="For users locked to a single patient, the server PK of the " 

517 "server-created patient with which they are associated", 

518 ) 

519 

520 # ------------------------------------------------------------------------- 

521 # Relationships 

522 # ------------------------------------------------------------------------- 

523 

524 user_group_memberships = relationship( 

525 "UserGroupMembership", back_populates="user" 

526 ) # type: _TYPE_LUGM 

527 groups = association_proxy( 

528 "user_group_memberships", "group" 

529 ) # type: List[Group] 

530 upload_group = relationship( 

531 "Group", foreign_keys=[upload_group_id] 

532 ) # type: Optional[Group] 

533 single_patient = relationship( 

534 "Patient", foreign_keys=[single_patient_pk] 

535 ) # type: Optional[Patient] 

536 

537 # ------------------------------------------------------------------------- 

538 # __init__ 

539 # ------------------------------------------------------------------------- 

540 

541 def __init__(self, **kwargs) -> None: 

542 super().__init__(**kwargs) 

543 # Prevent Python None from being converted to database string 'none'. 

544 self.mfa_method = kwargs.get("mfa_method", MfaMethod.NO_MFA) 

545 

546 # ------------------------------------------------------------------------- 

547 # String representations 

548 # ------------------------------------------------------------------------- 

549 

550 def __repr__(self) -> str: 

551 return simple_repr( 

552 self, ["id", "username", "fullname"], with_addr=True 

553 ) 

554 

555 # ------------------------------------------------------------------------- 

556 # Lookup methods 

557 # ------------------------------------------------------------------------- 

558 

559 @classmethod 

560 def get_user_by_id( 

561 cls, dbsession: SqlASession, user_id: Optional[int] 

562 ) -> Optional["User"]: 

563 """ 

564 Returns a User from their integer ID, or ``None``. 

565 """ 

566 if user_id is None: 

567 return None 

568 return dbsession.query(cls).filter(cls.id == user_id).first() 

569 

570 @classmethod 

571 def get_user_by_name( 

572 cls, dbsession: SqlASession, username: str 

573 ) -> Optional["User"]: 

574 """ 

575 Returns a User from their username, or ``None``. 

576 """ 

577 if not username: 

578 return None 

579 return dbsession.query(cls).filter(cls.username == username).first() 

580 

581 @classmethod 

582 def user_exists(cls, req: "CamcopsRequest", username: str) -> bool: 

583 """ 

584 Does a user exist with this username? 

585 """ 

586 if not username: 

587 return False 

588 dbsession = req.dbsession 

589 return exists_orm(dbsession, cls, cls.username == username) 

590 

591 @classmethod 

592 def create_superuser( 

593 cls, req: "CamcopsRequest", username: str, password: str 

594 ) -> bool: 

595 """ 

596 Creates a superuser. 

597 

598 Will fail if the user already exists. 

599 

600 Args: 

601 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

602 username: the new superuser's username 

603 password: the new superuser's password 

604 

605 Returns: 

606 success? 

607 

608 """ 

609 assert username, "Can't create superuser with no name" 

610 assert ( 

611 username != USER_NAME_FOR_SYSTEM 

612 ), f"Can't create user with name {USER_NAME_FOR_SYSTEM!r}" 

613 dbsession = req.dbsession 

614 user = cls.get_user_by_name(dbsession, username) 

615 if user: 

616 # already exists! 

617 return False 

618 # noinspection PyArgumentList 

619 user = cls(username=username) # does work! 

620 user.superuser = True 

621 audit(req, "SUPERUSER CREATED: " + user.username, from_console=True) 

622 user.set_password(req, password) # will audit 

623 user.language = req.language # a reasonable default 

624 dbsession.add(user) 

625 return True 

626 

627 @classmethod 

628 def get_username_from_id( 

629 cls, req: "CamcopsRequest", user_id: int 

630 ) -> Optional[str]: 

631 """ 

632 Looks up a user from their integer ID and returns their name, if found. 

633 """ 

634 dbsession = req.dbsession 

635 return ( 

636 dbsession.query(cls.username) 

637 .filter(cls.id == user_id) 

638 .first() 

639 .scalar() 

640 ) 

641 

642 @classmethod 

643 def get_user_from_username_password( 

644 cls, 

645 req: "CamcopsRequest", 

646 username: str, 

647 password: str, 

648 take_time_for_nonexistent_user: bool = True, 

649 ) -> Optional["User"]: 

650 """ 

651 Retrieve a User object from the supplied username, if the password is 

652 correct; otherwise, return None. 

653 

654 Args: 

655 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

656 username: the username 

657 password: the password attempt 

658 take_time_for_nonexistent_user: if ``True`` (the default), then 

659 even if the user doesn't exist, we take some time to mimic 

660 the time we spend doing deliberately wasteful password 

661 encryption (to prevent attackers from discovering real 

662 usernames via timing attacks). 

663 """ 

664 dbsession = req.dbsession 

665 user = cls.get_user_by_name(dbsession, username) 

666 if user is None: 

667 if take_time_for_nonexistent_user: 

668 # If the user really existed, we'd be running a somewhat 

669 # time-consuming bcrypt operation. So that attackers can't 

670 # identify fake users easily based on timing, we consume some 

671 # time: 

672 cls.take_some_time_mimicking_password_encryption() 

673 return None 

674 if not user.is_password_correct(password): 

675 return None 

676 return user 

677 

678 @classmethod 

679 def get_system_user(cls, dbsession: SqlASession) -> "User": 

680 """ 

681 Returns a user representing "command-line access". 

682 """ 

683 user = cls.get_user_by_name(dbsession, USER_NAME_FOR_SYSTEM) 

684 if not user: 

685 # noinspection PyArgumentList 

686 user = cls(username=USER_NAME_FOR_SYSTEM) # does work! 

687 dbsession.add(user) 

688 user.fullname = "CamCOPS system user" 

689 user.superuser = True 

690 user.hashedpw = "" # because it's not nullable 

691 # ... note that no password will hash to '', in addition to the fact 

692 # that the system will not allow logon attempts for this user! 

693 return user 

694 

695 # ------------------------------------------------------------------------- 

696 # Static methods 

697 # ------------------------------------------------------------------------- 

698 

699 @staticmethod 

700 def is_username_permissible(username: str) -> bool: 

701 """ 

702 Is this a permissible username? 

703 """ 

704 return bool(re.match(VALID_USERNAME_REGEX, username)) 

705 

706 @staticmethod 

707 def take_some_time_mimicking_password_encryption() -> None: 

708 """ 

709 Waste some time. We use this when an attempt has been made to log in 

710 with a nonexistent user; we know the user doesn't exist very quickly, 

711 but we mimic the time it takes to check a real user's password. 

712 """ 

713 rnc_crypto.hash_password("dummy!", BCRYPT_DEFAULT_LOG_ROUNDS) 

714 

715 # ------------------------------------------------------------------------- 

716 # Authentication: passwords 

717 # ------------------------------------------------------------------------- 

718 

719 def set_password(self, req: "CamcopsRequest", new_password: str) -> None: 

720 """ 

721 Set a user's password. 

722 """ 

723 self.hashedpw = rnc_crypto.hash_password( 

724 new_password, BCRYPT_DEFAULT_LOG_ROUNDS 

725 ) 

726 self.last_password_change_utc = req.now_utc_no_tzinfo 

727 self.must_change_password = False 

728 audit(req, "Password changed for user " + self.username) 

729 

730 def is_password_correct(self, password: str) -> bool: 

731 """ 

732 Is the supplied password valid for this user? 

733 """ 

734 return rnc_crypto.is_password_valid(password, self.hashedpw) 

735 

736 def force_password_change(self) -> None: 

737 """ 

738 Make the user change their password at next login. 

739 """ 

740 self.must_change_password = True 

741 

742 def set_password_change_flag_if_necessary( 

743 self, req: "CamcopsRequest" 

744 ) -> None: 

745 """ 

746 If we're requiring users to change their passwords, then check to 

747 see if they must do so now. 

748 """ 

749 if self.must_change_password: 

750 # already required, pointless to check again 

751 return 

752 cfg = req.config 

753 if cfg.password_change_frequency_days <= 0: 

754 # changes never required 

755 return 

756 if not self.last_password_change_utc: 

757 # we don't know when the last change was, so it's overdue 

758 self.force_password_change() 

759 return 

760 delta = req.now_utc_no_tzinfo - self.last_password_change_utc 

761 # Must use a version of "now" with no timezone info, since 

762 # self.last_password_change_utc is "offset-naive" (has no timezone 

763 # info) 

764 if delta.days >= cfg.password_change_frequency_days: 

765 self.force_password_change() 

766 

767 # ------------------------------------------------------------------------- 

768 # Authentication: multi-factor authentication 

769 # ------------------------------------------------------------------------- 

770 

771 def set_mfa_method(self, mfa_method: str) -> None: 

772 """ 

773 Resets the multi-factor authentication (MFA) method. 

774 """ 

775 assert MfaMethod.valid( 

776 mfa_method 

777 ), f"Invalid MFA method: {mfa_method!r}" 

778 

779 # Set the method 

780 self.mfa_method = mfa_method 

781 

782 # A new secret key 

783 self.mfa_secret_key = pyotp.random_base32() 

784 

785 # Reset the HOTP counter 

786 self.hotp_counter = 0 

787 

788 def ensure_mfa_info(self) -> None: 

789 """ 

790 If for some reason we have lost aspects of our MFA information, 

791 reset it. This step also ensures that anything erroneous in the 

792 database is cleaned to a valid value. 

793 """ 

794 if not self.mfa_secret_key or self.hotp_counter is None: 

795 self.set_mfa_method(MfaMethod.clean(self.mfa_method)) 

796 

797 def verify_one_time_password(self, one_time_password: str) -> bool: 

798 """ 

799 Determines whether the supplied one-time password is valid for the 

800 multi-factor authentication (MFA) currently selected. 

801 

802 Returns ``False`` if no MFA method is selected. 

803 """ 

804 mfa_method = self.mfa_method 

805 

806 if not MfaMethod.requires_second_step(mfa_method): 

807 return False 

808 

809 if mfa_method == MfaMethod.TOTP: 

810 totp = pyotp.TOTP(self.mfa_secret_key) 

811 return totp.verify(one_time_password) 

812 

813 elif mfa_method in (MfaMethod.HOTP_EMAIL, MfaMethod.HOTP_SMS): 

814 hotp = pyotp.HOTP(self.mfa_secret_key) 

815 return one_time_password == hotp.at(self.hotp_counter) 

816 

817 else: 

818 raise ValueError( 

819 f"User.verify_one_time_password(): " 

820 f"Bad mfa_method = {mfa_method}" 

821 ) 

822 

823 # ------------------------------------------------------------------------- 

824 # Authentication: logging in 

825 # ------------------------------------------------------------------------- 

826 

827 def login(self, req: "CamcopsRequest") -> None: 

828 """ 

829 Called when the framework has determined a successful login. 

830 

831 Clears any login failures. 

832 Requires the user to change their password if policies say they should. 

833 """ 

834 self.clear_login_failures(req) 

835 self.set_password_change_flag_if_necessary(req) 

836 self.last_login_at_utc = req.now_utc_no_tzinfo 

837 

838 def clear_login_failures(self, req: "CamcopsRequest") -> None: 

839 """ 

840 Clear login failures. 

841 """ 

842 if not self.username: 

843 return 

844 SecurityLoginFailure.clear_login_failures(req, self.username) 

845 

846 def is_locked_out(self, req: "CamcopsRequest") -> bool: 

847 """ 

848 Is the user locked out because of multiple login failures? 

849 """ 

850 return SecurityAccountLockout.is_user_locked_out(req, self.username) 

851 

852 def locked_out_until(self, req: "CamcopsRequest") -> Optional[Pendulum]: 

853 """ 

854 When is the user locked out until? 

855 

856 Returns a Pendulum datetime in local timezone (or ``None`` if the 

857 user isn't locked out). 

858 """ 

859 return SecurityAccountLockout.user_locked_out_until(req, self.username) 

860 

861 def enable(self, req: "CamcopsRequest") -> None: 

862 """ 

863 Re-enables the user, unlocking them and clearing login failures. 

864 """ 

865 SecurityLoginFailure.enable_user(req, self.username) 

866 

867 # ------------------------------------------------------------------------- 

868 # Details used for authentication 

869 # ------------------------------------------------------------------------- 

870 

871 @property 

872 def partial_email(self) -> str: 

873 """ 

874 Returns a partially obscured version of the user's e-mail address. 

875 

876 There doesn't seem to be an agreed way of doing this. Here we show the 

877 first and last letter of the "local-part" (see 

878 https://en.wikipedia.org/wiki/Email_address), separated by asterisks. 

879 If the local part is a single letter, it's shown twice. 

880 """ 

881 regex = r"^(.+)@(.*)$" 

882 

883 m = re.search(regex, self.email) 

884 first_letter = m.group(1)[0] 

885 last_letter = m.group(1)[-1] 

886 domain = m.group(2) 

887 

888 return f"{first_letter}{OBSCURE_EMAIL_ASTERISKS}{last_letter}@{domain}" 

889 

890 @property 

891 def raw_phone_number(self) -> str: 

892 """ 

893 Returns the user's phone number in E164 format: 

894 https://en.wikipedia.org/wiki/E.164 

895 """ 

896 return phonenumbers.format_number( 

897 self.phone_number, phonenumbers.PhoneNumberFormat.E164 

898 ) 

899 

900 @property 

901 def partial_phone_number(self) -> str: 

902 """ 

903 Returns a partially obscured version of the user's phone number. 

904 

905 There doesn't seem to be an agreed way of doing this either. 

906 https://www.karansaini.com/fuzzing-obfuscated-phone-numbers/ 

907 """ 

908 return f"{OBSCURE_PHONE_ASTERISKS}{self.raw_phone_number[-2:]}" 

909 

910 # ------------------------------------------------------------------------- 

911 # Requirements 

912 # ------------------------------------------------------------------------- 

913 

914 @property 

915 def must_agree_terms(self) -> bool: 

916 """ 

917 Does the user still need to agree the terms/conditions of use? 

918 """ 

919 if self.when_agreed_terms_of_use is None: 

920 # User hasn't agreed yet. 

921 return True 

922 if self.when_agreed_terms_of_use.date() < TERMS_CONDITIONS_UPDATE_DATE: 

923 # User hasn't agreed since the terms were updated. 

924 return True 

925 return False 

926 

927 def agree_terms(self, req: "CamcopsRequest") -> None: 

928 """ 

929 Mark the user as having agreed to the terms/conditions of use now. 

930 """ 

931 self.when_agreed_terms_of_use = req.now 

932 

933 def must_set_mfa_method(self, req: "CamcopsRequest") -> bool: 

934 """ 

935 Does the user still need to select a (valid) multi-factor 

936 authentication method? We are happy if the user has selected a method 

937 that is approved in the current config. 

938 """ 

939 return self.mfa_method not in req.config.mfa_methods 

940 

941 # ------------------------------------------------------------------------- 

942 # Groups 

943 # ------------------------------------------------------------------------- 

944 

945 @property 

946 def group_ids(self) -> List[int]: 

947 """ 

948 Return a list of group IDs for all the groups that the user is a member 

949 of. 

950 """ 

951 return sorted(list(g.id for g in self.groups)) 

952 

953 @property 

954 def group_names(self) -> List[str]: 

955 """ 

956 Returns a list of group names for all the groups that the user is a 

957 member of. 

958 """ 

959 return sorted(list(g.name for g in self.groups)) 

960 

961 def set_group_ids(self, group_ids: List[int]) -> None: 

962 """ 

963 Set the user's groups to the groups whose integer IDs are in the 

964 ``group_ids`` list, and remove the user from any other groups. 

965 """ 

966 dbsession = SqlASession.object_session(self) 

967 assert dbsession, ( 

968 "User.set_group_ids() called on a User that's not " 

969 "yet in a session" 

970 ) 

971 # groups = Group.get_groups_from_id_list(dbsession, group_ids) 

972 

973 # Remove groups that no longer apply 

974 for m in self.user_group_memberships: 

975 if m.group_id not in group_ids: 

976 dbsession.delete(m) 

977 # Add new groups 

978 current_group_ids = [m.group_id for m in self.user_group_memberships] 

979 new_group_ids = [ 

980 gid for gid in group_ids if gid not in current_group_ids 

981 ] 

982 for gid in new_group_ids: 

983 self.user_group_memberships.append( 

984 UserGroupMembership(user_id=self.id, group_id=gid) 

985 ) 

986 

987 @property 

988 def ids_of_groups_user_may_see(self) -> List[int]: 

989 """ 

990 Return a list of group IDs for groups that the user may see data 

991 from. (That means the groups the user is in, plus any other groups that 

992 the user's groups are authorized to see.) 

993 """ 

994 # Incidentally: "list_a += list_b" vs "list_a.extend(list_b)": 

995 # https://stackoverflow.com/questions/3653298/concatenating-two-lists-difference-between-and-extend # noqa 

996 # ... not much difference; perhaps += is slightly better (also clearer) 

997 # And relevant set operations: 

998 # https://stackoverflow.com/questions/4045403/python-how-to-add-the-contents-of-an-iterable-to-a-set # noqa 

999 # 

1000 # Process as a set rather than a list, to eliminate duplicates: 

1001 group_ids = set() # type: Set[int] 

1002 for my_group in self.groups: # type: Group 

1003 group_ids.update(my_group.ids_of_groups_group_may_see()) 

1004 return list(group_ids) 

1005 # Return as a list rather than a set, because SQLAlchemy's in_() 

1006 # operator only likes lists and ?tuples. 

1007 

1008 @property 

1009 def ids_of_groups_user_may_dump(self) -> List[int]: 

1010 """ 

1011 Return a list of group IDs for groups that the user may dump data 

1012 from. 

1013 

1014 See also :meth:`groups_user_may_dump`. 

1015 

1016 This does **not** give "second-hand authority" to dump. For example, 

1017 if group G1 can "see" G2, and user U has authority to dump G1, that 

1018 authority does not extend to G2. 

1019 """ 

1020 if self.superuser: 

1021 return Group.all_group_ids( 

1022 dbsession=SqlASession.object_session(self) 

1023 ) 

1024 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1025 return [m.group_id for m in memberships if m.may_dump_data] 

1026 

1027 @property 

1028 def ids_of_groups_user_may_report_on(self) -> List[int]: 

1029 """ 

1030 Returns a list of group IDs for groups that the user may run reports 

1031 on. 

1032 

1033 This does **not** give "second-hand authority" to dump. For example, 

1034 if group G1 can "see" G2, and user U has authority to report on G1, 

1035 that authority does not extend to G2. 

1036 """ 

1037 if self.superuser: 

1038 return Group.all_group_ids( 

1039 dbsession=SqlASession.object_session(self) 

1040 ) 

1041 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1042 return [m.group_id for m in memberships if m.may_run_reports] 

1043 

1044 @property 

1045 def ids_of_groups_user_is_admin_for(self) -> List[int]: 

1046 """ 

1047 Returns a list of group IDs for groups that the user is an 

1048 administrator for. 

1049 """ 

1050 if self.superuser: 

1051 return Group.all_group_ids( 

1052 dbsession=SqlASession.object_session(self) 

1053 ) 

1054 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1055 return [m.group_id for m in memberships if m.groupadmin] 

1056 

1057 @property 

1058 def ids_of_groups_user_may_manage_patients_in(self) -> List[int]: 

1059 """ 

1060 Returns a list of group IDs for groups that the user may 

1061 add/edit/delete patients in 

1062 """ 

1063 if self.superuser: 

1064 return Group.all_group_ids( 

1065 dbsession=SqlASession.object_session(self) 

1066 ) 

1067 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1068 return [ 

1069 m.group_id 

1070 for m in memberships 

1071 if m.may_manage_patients or m.groupadmin 

1072 ] 

1073 

1074 @property 

1075 def ids_of_groups_user_may_email_patients_in(self) -> List[int]: 

1076 """ 

1077 Returns a list of group IDs for groups that the user may send emails to 

1078 patients in 

1079 """ 

1080 if self.superuser: 

1081 return Group.all_group_ids( 

1082 dbsession=SqlASession.object_session(self) 

1083 ) 

1084 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1085 return [ 

1086 m.group_id 

1087 for m in memberships 

1088 if m.may_email_patients or m.groupadmin 

1089 ] 

1090 

1091 @property 

1092 def names_of_groups_user_is_admin_for(self) -> List[str]: 

1093 """ 

1094 Returns a list of group names for groups that the user is an 

1095 administrator for. 

1096 """ 

1097 if self.superuser: 

1098 return Group.all_group_names( 

1099 dbsession=SqlASession.object_session(self) 

1100 ) 

1101 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1102 return [m.group.name for m in memberships if m.groupadmin] 

1103 

1104 @property 

1105 def names_of_groups_user_is_admin_for_csv(self) -> str: 

1106 """ 

1107 Returns a list of group names for groups that the user is an 

1108 administrator for. 

1109 """ 

1110 names = sorted(self.names_of_groups_user_is_admin_for) 

1111 return ", ".join(names) 

1112 

1113 def may_administer_group(self, group_id: int) -> bool: 

1114 """ 

1115 May this user administer the group identified by ``group_id``? 

1116 """ 

1117 if self.superuser: 

1118 return True 

1119 return group_id in self.ids_of_groups_user_is_admin_for 

1120 

1121 def may_manage_patients_in_group(self, group_id: int) -> bool: 

1122 """ 

1123 May this user manage patients in the group identified by ``group_id``? 

1124 """ 

1125 if self.superuser: 

1126 return True 

1127 return group_id in self.ids_of_groups_user_may_manage_patients_in 

1128 

1129 def may_email_patients_in_group(self, group_id: int) -> bool: 

1130 """ 

1131 May this user send emails to patients in the group identified by 

1132 ``group_id``? 

1133 """ 

1134 if self.superuser: 

1135 return True 

1136 return group_id in self.ids_of_groups_user_may_email_patients_in 

1137 

1138 @property 

1139 def groups_user_may_see(self) -> List[Group]: 

1140 """ 

1141 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1142 objects for groups the user can see. 

1143 

1144 Less efficient than the group ID version; for visual display (see 

1145 ``view_own_user_info.mako``). 

1146 

1147 """ 

1148 groups = set(self.groups) # type: Set[Group] 

1149 for my_group in self.groups: # type: Group 

1150 groups.update(set(my_group.can_see_other_groups)) 

1151 return sorted(list(groups), key=lambda g: g.name) 

1152 

1153 @property 

1154 def groups_user_may_dump(self) -> List[Group]: 

1155 """ 

1156 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1157 objects for groups the user can dump. 

1158 

1159 For security notes, see :meth:`ids_of_groups_user_may_dump`. 

1160 

1161 Less efficient than the group ID version (see 

1162 :meth:`ids_of_groups_user_may_dump`). This version is for visual 

1163 display (see ``view_own_user_info.mako``). 

1164 

1165 """ 

1166 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1167 return sorted( 

1168 [m.group for m in memberships if m.may_dump_data], 

1169 key=lambda g: g.name, 

1170 ) 

1171 

1172 @property 

1173 def groups_user_may_report_on(self) -> List[Group]: 

1174 """ 

1175 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1176 objects for groups the user can report on. 

1177 

1178 For security notes, see :meth:`ids_of_groups_user_may_report_on`. 

1179 

1180 Less efficient than the group ID version (see 

1181 :meth:`ids_of_groups_user_may_report_on`). This version is for visual 

1182 display (see ``view_own_user_info.mako``). 

1183 

1184 """ 

1185 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1186 return sorted( 

1187 [m.group for m in memberships if m.may_run_reports], 

1188 key=lambda g: g.name, 

1189 ) 

1190 

1191 @property 

1192 def groups_user_may_upload_into(self) -> List[Group]: 

1193 """ 

1194 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1195 objects for groups the user can upload into. 

1196 

1197 For visual display (see ``view_own_user_info.mako``). 

1198 

1199 """ 

1200 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1201 return sorted( 

1202 [m.group for m in memberships if m.may_upload], 

1203 key=lambda g: g.name, 

1204 ) 

1205 

1206 @property 

1207 def groups_user_may_add_special_notes(self) -> List[Group]: 

1208 """ 

1209 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1210 objects for groups the user can add special notes to. 

1211 

1212 For visual display (see ``view_own_user_info.mako``). 

1213 

1214 """ 

1215 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1216 return sorted( 

1217 [m.group for m in memberships if m.may_add_notes], 

1218 key=lambda g: g.name, 

1219 ) 

1220 

1221 @property 

1222 def groups_user_may_see_all_pts_when_unfiltered(self) -> List[Group]: 

1223 """ 

1224 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1225 objects for groups the user can see all patients when unfiltered. 

1226 

1227 For visual display (see ``view_own_user_info.mako``). 

1228 

1229 """ 

1230 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1231 return sorted( 

1232 [ 

1233 m.group 

1234 for m in memberships 

1235 if m.view_all_patients_when_unfiltered 

1236 ], 

1237 key=lambda g: g.name, 

1238 ) 

1239 

1240 @property 

1241 def groups_user_is_admin_for(self) -> List[Group]: 

1242 """ 

1243 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1244 objects for groups the user is an administrator for. 

1245 

1246 Less efficient than the group ID version; for visual display (see 

1247 ``view_own_user_info.mako``). 

1248 

1249 """ 

1250 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1251 return sorted( 

1252 [m.group for m in memberships if m.groupadmin], 

1253 key=lambda g: g.name, 

1254 ) 

1255 

1256 @property 

1257 def groups_user_may_manage_patients_in(self) -> List[Group]: 

1258 """ 

1259 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1260 objects for groups the user may manage patients in. 

1261 """ 

1262 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1263 return sorted( 

1264 [m.group for m in memberships if m.may_manage_patients], 

1265 key=lambda g: g.name, 

1266 ) 

1267 

1268 @property 

1269 def groups_user_may_email_patients_in(self) -> List[Group]: 

1270 """ 

1271 Returns a list of :class:`camcops_server.cc_modules.cc_group.Group` 

1272 objects for groups the user may send emails to patients in. 

1273 """ 

1274 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1275 return sorted( 

1276 [m.group for m in memberships if m.may_email_patients], 

1277 key=lambda g: g.name, 

1278 ) 

1279 

1280 @property 

1281 def is_a_groupadmin(self) -> bool: 

1282 """ 

1283 Is the user a specifically defined group administrator (for any group)? 

1284 """ 

1285 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1286 return any(m.groupadmin for m in memberships) 

1287 

1288 @property 

1289 def authorized_as_groupadmin(self) -> bool: 

1290 """ 

1291 Is the user authorized as a group administrator for any group (either 

1292 by being specifically set as a group administrator, or by being a 

1293 superuser)? 

1294 """ 

1295 return self.superuser or self.is_a_groupadmin 

1296 

1297 def membership_for_group_id(self, group_id: int) -> UserGroupMembership: 

1298 """ 

1299 Returns the :class:`UserGroupMembership` object relating this user 

1300 to the group identified by ``group_id``. 

1301 """ 

1302 return next( 

1303 (m for m in self.user_group_memberships if m.group_id == group_id), 

1304 None, 

1305 ) 

1306 

1307 def group_ids_nonsuperuser_may_see_when_unfiltered(self) -> List[int]: 

1308 """ 

1309 Which group IDs may this user see all patients for, when unfiltered? 

1310 """ 

1311 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1312 return [ 

1313 m.group_id 

1314 for m in memberships 

1315 if m.view_all_patients_when_unfiltered 

1316 ] 

1317 

1318 def may_upload_to_group(self, group_id: int) -> bool: 

1319 """ 

1320 May this user upload to the specified group? 

1321 """ 

1322 if self.superuser: 

1323 return True 

1324 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1325 return any(m.may_upload for m in memberships if m.group_id == group_id) 

1326 

1327 # ------------------------------------------------------------------------- 

1328 # Other permissions 

1329 # ------------------------------------------------------------------------- 

1330 

1331 @property 

1332 def may_login_as_tablet(self) -> bool: 

1333 """ 

1334 May the user login via the client (tablet) API? 

1335 """ 

1336 return self.may_upload or self.may_register_devices 

1337 

1338 @property 

1339 def may_use_webviewer(self) -> bool: 

1340 """ 

1341 May this user log in to the web front end? 

1342 """ 

1343 if self.superuser: 

1344 return True 

1345 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1346 return any(m.may_use_webviewer for m in memberships) 

1347 

1348 def authorized_to_add_special_note(self, group_id: int) -> bool: 

1349 """ 

1350 Is this user authorized to add special notes for the group identified 

1351 by ``group_id``? 

1352 """ 

1353 if self.superuser: 

1354 return True 

1355 membership = self.membership_for_group_id(group_id) 

1356 if not membership: 

1357 return False 

1358 return membership.may_add_notes 

1359 

1360 def authorized_to_erase_tasks(self, group_id: int) -> bool: 

1361 """ 

1362 Is this user authorized to erase tasks for the group identified 

1363 by ``group_id``? 

1364 """ 

1365 if self.superuser: 

1366 return True 

1367 membership = self.membership_for_group_id(group_id) 

1368 if not membership: 

1369 return False 

1370 return membership.groupadmin 

1371 

1372 @property 

1373 def authorized_to_dump(self) -> bool: 

1374 """ 

1375 Is the user authorized to dump data (for some group)? 

1376 """ 

1377 if self.superuser: 

1378 return True 

1379 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1380 return any(m.may_dump_data for m in memberships) 

1381 

1382 @property 

1383 def authorized_for_reports(self) -> bool: 

1384 """ 

1385 Is the user authorized to run reports (for some group)? 

1386 """ 

1387 if self.superuser: 

1388 return True 

1389 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1390 return any(m.may_run_reports for m in memberships) 

1391 

1392 @property 

1393 def authorized_to_manage_patients(self) -> bool: 

1394 """ 

1395 Is the user authorized to manage patients (for some group)? 

1396 """ 

1397 if self.authorized_as_groupadmin: 

1398 return True 

1399 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1400 return any(m.may_manage_patients for m in memberships) 

1401 

1402 @property 

1403 def authorized_to_email_patients(self) -> bool: 

1404 """ 

1405 Is the user authorized to send emails to patients (for some group)? 

1406 """ 

1407 if self.authorized_as_groupadmin: 

1408 return True 

1409 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1410 return any(m.may_email_patients for m in memberships) 

1411 

1412 @property 

1413 def may_view_all_patients_when_unfiltered(self) -> bool: 

1414 """ 

1415 May the user view all patients when no filters are applied (for all 

1416 groups that the user is a member of)? 

1417 """ 

1418 if self.superuser: 

1419 return True 

1420 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1421 return all(m.view_all_patients_when_unfiltered for m in memberships) 

1422 

1423 @property 

1424 def may_view_no_patients_when_unfiltered(self) -> bool: 

1425 """ 

1426 May the user view *no* patients when no filters are applied? 

1427 """ 

1428 if self.superuser: 

1429 return False 

1430 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1431 return all( 

1432 not m.view_all_patients_when_unfiltered for m in memberships 

1433 ) 

1434 

1435 @property 

1436 def may_upload(self) -> bool: 

1437 """ 

1438 May this user upload to the group that is set as their upload group? 

1439 """ 

1440 if self.upload_group_id is None: 

1441 return False 

1442 return self.may_upload_to_group(self.upload_group_id) 

1443 

1444 @property 

1445 def may_register_devices(self) -> bool: 

1446 """ 

1447 May this user register devices? 

1448 

1449 You can register a device if your chosen upload groups allow you to do 

1450 so. (You have to have a chosen group -- even for superusers -- because 

1451 the tablet wants group ID policies at the moment of registration, so we 

1452 have to know which group.) 

1453 """ 

1454 if self.upload_group_id is None: 

1455 return False 

1456 if self.superuser: 

1457 return True 

1458 memberships = self.user_group_memberships # type: _TYPE_LUGM 

1459 return any( 

1460 m.may_register_devices 

1461 for m in memberships 

1462 if m.group_id == self.upload_group_id 

1463 ) 

1464 

1465 # ------------------------------------------------------------------------- 

1466 # Managing other users 

1467 # ------------------------------------------------------------------------- 

1468 

1469 def managed_users(self) -> Optional[Query]: 

1470 """ 

1471 Return a query for all users managed by this user. 

1472 

1473 LOGIC SHOULD MATCH :meth:`may_edit_user`. 

1474 """ 

1475 dbsession = SqlASession.object_session(self) 

1476 if not self.superuser and not self.is_a_groupadmin: 

1477 return dbsession.query(User).filter(false()) 

1478 # https://stackoverflow.com/questions/10345327/sqlalchemy-create-an-intentionally-empty-query # noqa 

1479 q = ( 

1480 dbsession.query(User) 

1481 .filter(User.username != USER_NAME_FOR_SYSTEM) 

1482 .order_by(User.username) 

1483 ) 

1484 if not self.superuser: 

1485 # LOGIC SHOULD MATCH assert_may_edit_user 

1486 # Restrict to users who are members of groups that I am an admin 

1487 # for: 

1488 groupadmin_group_ids = self.ids_of_groups_user_is_admin_for 

1489 # noinspection PyUnresolvedReferences 

1490 ugm2 = UserGroupMembership.__table__.alias("ugm2") 

1491 q = ( 

1492 q.join(User.user_group_memberships) 

1493 .filter(not_(User.superuser)) 

1494 .filter(UserGroupMembership.group_id.in_(groupadmin_group_ids)) 

1495 .filter( 

1496 ~exists() 

1497 .select_from(ugm2) 

1498 .where(and_(ugm2.c.user_id == User.id, ugm2.c.groupadmin)) 

1499 ) 

1500 ) 

1501 # ... no superusers 

1502 # ... user must be a member of one of our groups 

1503 # ... no groupadmins 

1504 # https://stackoverflow.com/questions/14600619/using-not-exists-clause-in-sqlalchemy-orm-query # noqa 

1505 return q 

1506 

1507 def may_edit_user( 

1508 self, req: "CamcopsRequest", other: "User" 

1509 ) -> Tuple[bool, str]: 

1510 """ 

1511 May the ``self`` user edit the ``other`` user? 

1512 

1513 Args: 

1514 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

1515 other: the user to be edited (potentially) 

1516 

1517 Returns: 

1518 tuple: may_edit (bool), reason_why_not (str) 

1519 

1520 LOGIC SHOULD MATCH :meth:`managed_users`. 

1521 """ 

1522 _ = req.gettext 

1523 if other.username == USER_NAME_FOR_SYSTEM: 

1524 return False, _("Nobody may edit the system user") 

1525 if not self.superuser: 

1526 if other.superuser: 

1527 return False, _("You may not edit a superuser") 

1528 if other.is_a_groupadmin: 

1529 return False, _("You may not edit a group administrator") 

1530 groupadmin_group_ids = self.ids_of_groups_user_is_admin_for 

1531 if not any(gid in groupadmin_group_ids for gid in other.group_ids): 

1532 return ( 

1533 False, 

1534 _( 

1535 "You are not a group administrator for any " 

1536 "groups that this user is in" 

1537 ), 

1538 ) 

1539 return True, "" 

1540 

1541 

1542# ============================================================================= 

1543# Command-line password control 

1544# ============================================================================= 

1545 

1546 

1547def set_password_directly( 

1548 req: "CamcopsRequest", username: str, password: str 

1549) -> bool: 

1550 """ 

1551 If the user exists, set its password. Returns Boolean success. 

1552 Used from the command line. 

1553 """ 

1554 dbsession = req.dbsession 

1555 user = User.get_user_by_name(dbsession, username) 

1556 if not user: 

1557 return False 

1558 user.set_password(req, password) 

1559 user.enable(req) 

1560 audit(req, "Password changed for user " + user.username, from_console=True) 

1561 return True