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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_user.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**CamCOPS users.**
30"""
32import datetime
33import logging
34import re
35from typing import List, Optional, Set, Tuple, TYPE_CHECKING
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
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
80if TYPE_CHECKING:
81 from camcops_server.cc_modules.cc_patient import Patient
82 from camcops_server.cc_modules.cc_request import CamcopsRequest
84log = BraceStyleAdapter(logging.getLogger(__name__))
87# =============================================================================
88# Constants
89# =============================================================================
91_TYPE_LUGM = List[UserGroupMembership]
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
104CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS = 7
105CLEAR_DUMMY_LOGIN_PERIOD = datetime.timedelta(
106 days=CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS
107)
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)
117class SecurityAccountLockout(Base):
118 """
119 Represents an account "lockout".
120 """
122 __tablename__ = "_security_account_lockouts"
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 )
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 )
152 @classmethod
153 def is_user_locked_out(cls, req: "CamcopsRequest", username: str) -> bool:
154 """
155 Is the specified user locked out?
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 )
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?
174 Args:
175 req: :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
176 username: the user's username
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)
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.
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 )
217 @classmethod
218 def unlock_user(cls, req: "CamcopsRequest", username: str) -> None:
219 """
220 Unlock a user.
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 )
232# =============================================================================
233# SecurityLoginFailure
234# =============================================================================
237class SecurityLoginFailure(Base):
238 """
239 Represents a record of a failed login.
241 Too many failed logins lead to a lockout; see
242 :class:`SecurityAccountLockout`.
243 """
245 __tablename__ = "_security_login_failures"
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 )
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.
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)
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.
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 )
307 @classmethod
308 def clear_login_failures(
309 cls, req: "CamcopsRequest", username: str
310 ) -> None:
311 """
312 Clear login failures for a user.
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 )
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)?
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()
340 @classmethod
341 def enable_user(cls, req: "CamcopsRequest", username: str) -> None:
342 """
343 Unlock user and clear login failures.
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")
353 @classmethod
354 def clear_login_failures_for_nonexistent_users(
355 cls, req: "CamcopsRequest"
356 ) -> None:
357 """
358 Clear login failures for nonexistent users.
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.
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
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.
381 Not too often! See :data:`CLEAR_DUMMY_LOGIN_PERIOD`.
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
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
402# =============================================================================
403# User class
404# =============================================================================
407class User(Base):
408 """
409 Class representing a user.
410 """
412 __tablename__ = "_security_users"
414 # -------------------------------------------------------------------------
415 # Columns
416 # -------------------------------------------------------------------------
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 )
520 # -------------------------------------------------------------------------
521 # Relationships
522 # -------------------------------------------------------------------------
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]
537 # -------------------------------------------------------------------------
538 # __init__
539 # -------------------------------------------------------------------------
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)
546 # -------------------------------------------------------------------------
547 # String representations
548 # -------------------------------------------------------------------------
550 def __repr__(self) -> str:
551 return simple_repr(
552 self, ["id", "username", "fullname"], with_addr=True
553 )
555 # -------------------------------------------------------------------------
556 # Lookup methods
557 # -------------------------------------------------------------------------
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()
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()
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)
591 @classmethod
592 def create_superuser(
593 cls, req: "CamcopsRequest", username: str, password: str
594 ) -> bool:
595 """
596 Creates a superuser.
598 Will fail if the user already exists.
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
605 Returns:
606 success?
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
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 )
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.
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
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
695 # -------------------------------------------------------------------------
696 # Static methods
697 # -------------------------------------------------------------------------
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))
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)
715 # -------------------------------------------------------------------------
716 # Authentication: passwords
717 # -------------------------------------------------------------------------
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)
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)
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
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()
767 # -------------------------------------------------------------------------
768 # Authentication: multi-factor authentication
769 # -------------------------------------------------------------------------
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}"
779 # Set the method
780 self.mfa_method = mfa_method
782 # A new secret key
783 self.mfa_secret_key = pyotp.random_base32()
785 # Reset the HOTP counter
786 self.hotp_counter = 0
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))
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.
802 Returns ``False`` if no MFA method is selected.
803 """
804 mfa_method = self.mfa_method
806 if not MfaMethod.requires_second_step(mfa_method):
807 return False
809 if mfa_method == MfaMethod.TOTP:
810 totp = pyotp.TOTP(self.mfa_secret_key)
811 return totp.verify(one_time_password)
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)
817 else:
818 raise ValueError(
819 f"User.verify_one_time_password(): "
820 f"Bad mfa_method = {mfa_method}"
821 )
823 # -------------------------------------------------------------------------
824 # Authentication: logging in
825 # -------------------------------------------------------------------------
827 def login(self, req: "CamcopsRequest") -> None:
828 """
829 Called when the framework has determined a successful login.
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
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)
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)
852 def locked_out_until(self, req: "CamcopsRequest") -> Optional[Pendulum]:
853 """
854 When is the user locked out until?
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)
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)
867 # -------------------------------------------------------------------------
868 # Details used for authentication
869 # -------------------------------------------------------------------------
871 @property
872 def partial_email(self) -> str:
873 """
874 Returns a partially obscured version of the user's e-mail address.
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"^(.+)@(.*)$"
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)
888 return f"{first_letter}{OBSCURE_EMAIL_ASTERISKS}{last_letter}@{domain}"
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 )
900 @property
901 def partial_phone_number(self) -> str:
902 """
903 Returns a partially obscured version of the user's phone number.
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:]}"
910 # -------------------------------------------------------------------------
911 # Requirements
912 # -------------------------------------------------------------------------
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
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
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
941 # -------------------------------------------------------------------------
942 # Groups
943 # -------------------------------------------------------------------------
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))
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))
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)
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 )
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.
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.
1014 See also :meth:`groups_user_may_dump`.
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]
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.
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]
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]
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 ]
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 ]
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]
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)
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
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
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
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.
1144 Less efficient than the group ID version; for visual display (see
1145 ``view_own_user_info.mako``).
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)
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.
1159 For security notes, see :meth:`ids_of_groups_user_may_dump`.
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``).
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 )
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.
1178 For security notes, see :meth:`ids_of_groups_user_may_report_on`.
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``).
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 )
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.
1197 For visual display (see ``view_own_user_info.mako``).
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 )
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.
1212 For visual display (see ``view_own_user_info.mako``).
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 )
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.
1227 For visual display (see ``view_own_user_info.mako``).
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 )
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.
1246 Less efficient than the group ID version; for visual display (see
1247 ``view_own_user_info.mako``).
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 )
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 )
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 )
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)
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
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 )
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 ]
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)
1327 # -------------------------------------------------------------------------
1328 # Other permissions
1329 # -------------------------------------------------------------------------
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
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)
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
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
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)
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)
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)
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)
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)
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 )
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)
1444 @property
1445 def may_register_devices(self) -> bool:
1446 """
1447 May this user register devices?
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 )
1465 # -------------------------------------------------------------------------
1466 # Managing other users
1467 # -------------------------------------------------------------------------
1469 def managed_users(self) -> Optional[Query]:
1470 """
1471 Return a query for all users managed by this user.
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
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?
1513 Args:
1514 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
1515 other: the user to be edited (potentially)
1517 Returns:
1518 tuple: may_edit (bool), reason_why_not (str)
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, ""
1542# =============================================================================
1543# Command-line password control
1544# =============================================================================
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