#!/usr/bin/env python
# camcops_server/cc_modules/cc_user.py
"""
===============================================================================
Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).
This file is part of CamCOPS.
CamCOPS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CamCOPS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CamCOPS. If not, see <http://www.gnu.org/licenses/>.
===============================================================================
"""
import datetime
import logging
import re
from typing import List, Optional, Set, TYPE_CHECKING
import cardinal_pythonlib.crypto as rnc_crypto
from cardinal_pythonlib.datetimefunc import convert_datetime_to_local
from cardinal_pythonlib.logs import BraceStyleAdapter
from cardinal_pythonlib.reprfunc import simple_repr
from cardinal_pythonlib.sqlalchemy.orm_query import (
CountStarSpecializedQuery,
exists_orm,
)
from pendulum import DateTime as Pendulum
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship, Session as SqlASession
from sqlalchemy.sql.functions import func
from sqlalchemy.sql.schema import Column, ForeignKey
from sqlalchemy.sql.sqltypes import Boolean, DateTime, Integer
from .cc_audit import audit
from .cc_constants import USER_NAME_FOR_SYSTEM
from .cc_group import Group
from .cc_membership import UserGroupMembership
from .cc_sqla_coltypes import (
EmailAddressColType,
FullNameColType,
HashedPasswordColType,
PendulumDateTimeAsIsoTextColType,
UserNameColType,
)
from .cc_sqlalchemy import Base
from .cc_unittest import DemoDatabaseTestCase
if TYPE_CHECKING:
from .cc_request import CamcopsRequest
log = BraceStyleAdapter(logging.getLogger(__name__))
# =============================================================================
# Constants
# =============================================================================
VALID_USERNAME_REGEX = "^[A-Za-z0-9_-]+$"
BCRYPT_DEFAULT_LOG_ROUNDS = 6
# Default is 12, but it does impact on the tablet upload speed (cost per
# transaction). Time is expected to be proportional to 2^n, i.e. incrementing 1
# increases time by a factor of 2.
# Empirically, on egret:
# 2^12 rounds takes around 400 ms
# 2^8 rounds takes around 30 ms (as expected, 1/16 of the time as for 12)
# we'd like around 8 ms; http://security.stackexchange.com/questions/17207
# ... so we should be using 12 + log(8/400)/log(2) = 6 rounds
CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS = 7
CLEAR_DUMMY_LOGIN_PERIOD = datetime.timedelta(
days=CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS)
# =============================================================================
# SecurityAccountLockout
# =============================================================================
# Note that we record login failures for non-existent users, and pretend
# they're locked out (to prevent username discovery that way, by timing)
[docs]class SecurityAccountLockout(Base):
__tablename__ = "_security_account_lockouts"
id = Column("id", Integer, primary_key=True, autoincrement=True)
username = Column(
"username", UserNameColType,
nullable=False, index=True,
comment="User name (which may be a non-existent user, to prevent "
"subtle username discovery by careful timing)"
)
locked_until = Column(
"locked_until", DateTime,
nullable=False, index=True,
comment="Account is locked until (UTC)"
)
[docs] @classmethod
def delete_old_account_lockouts(cls, req: "CamcopsRequest") -> None:
"""Delete all expired account lockouts."""
dbsession = req.dbsession
now = req.now_utc
dbsession.query(cls)\
.filter(cls.locked_until <= now)\
.delete(synchronize_session=False)
@classmethod
def is_user_locked_out(cls, req: "CamcopsRequest", username: str) -> bool:
dbsession = req.dbsession
now = req.now_utc
return exists_orm(dbsession, cls,
cls.username == username,
cls.locked_until > now)
[docs] @classmethod
def user_locked_out_until(cls, req: "CamcopsRequest",
username: str) -> Optional[Pendulum]:
"""
When is the user locked out until?
Returns datetime in local timezone (or None).
"""
dbsession = req.dbsession
now = req.now_utc
locked_until_utc = dbsession.query(func.max(cls.locked_until))\
.filter(cls.username == username)\
.filter(cls.locked_until > now)\
.scalar() # type: Optional[Pendulum]
# ... NOT first(), which returns (result,); we want just result
if not locked_until_utc:
return None
return convert_datetime_to_local(locked_until_utc)
[docs] @classmethod
def lock_user_out(cls, req: "CamcopsRequest",
username: str, lockout_minutes: int) -> None:
"""
Lock user out for a specified number of minutes.
"""
dbsession = req.dbsession
now = req.now_utc
lock_until = now + datetime.timedelta(minutes=lockout_minutes)
# noinspection PyArgumentList
lock = cls(username=username, lock_until=lock_until)
dbsession.add(lock)
audit(req, "Account {} locked out for {} minutes".format(
username, lockout_minutes))
@classmethod
def unlock_user(cls, req: "CamcopsRequest", username: str) -> None:
dbsession = req.dbsession
dbsession.query(cls)\
.filter(cls.username == username)\
.delete(synchronize_session=False)
# =============================================================================
# SecurityLoginFailure
# =============================================================================
[docs]class SecurityLoginFailure(Base):
__tablename__ = "_security_login_failures"
id = Column("id", Integer, primary_key=True, autoincrement=True)
username = Column(
"username", UserNameColType,
nullable=False, index=True,
comment="User name (which may be a non-existent user, to prevent "
"subtle username discovery by careful timing)"
)
login_failure_at = Column(
"login_failure_at", DateTime,
nullable=False, index=True,
comment="Login failure occurred at (UTC)"
)
[docs] @classmethod
def record_login_failure(cls, req: "CamcopsRequest",
username: str) -> None:
"""Record that a user has failed to log in."""
dbsession = req.dbsession
now = req.now_utc
# noinspection PyArgumentList
failure = cls(username=username, login_failure_at=now)
dbsession.add(failure)
[docs] @classmethod
def act_on_login_failure(cls, req: "CamcopsRequest",
username: str) -> None:
"""Record login failure and lock out user if necessary."""
cfg = req.config
audit(req, "Failed login as user: {}".format(username))
cls.record_login_failure(req, username)
nfailures = cls.how_many_login_failures(req, username)
nlockouts = nfailures // cfg.lockout_threshold
nfailures_since_last_lockout = nfailures % cfg.lockout_threshold
if nlockouts >= 1 and nfailures_since_last_lockout == 0:
# new lockout required
lockout_minutes = nlockouts * \
cfg.lockout_duration_increment_minutes
SecurityAccountLockout.lock_user_out(req, username,
lockout_minutes)
[docs] @classmethod
def clear_login_failures(cls, req: "CamcopsRequest",
username: str) -> None:
"""Clear login failures for a user."""
dbsession = req.dbsession
dbsession.query(cls)\
.filter(cls.username == username)\
.delete(synchronize_session=False)
[docs] @classmethod
def how_many_login_failures(cls, req: "CamcopsRequest",
username: str) -> int:
"""How many times has the user failed to log in (recently)?"""
dbsession = req.dbsession
q = CountStarSpecializedQuery([cls], session=dbsession)\
.filter(cls.username == username)
return q.count_star()
[docs] @classmethod
def enable_user(cls, req: "CamcopsRequest", username: str) -> None:
"""Unlock user and clear login failures."""
SecurityAccountLockout.unlock_user(req, username)
cls.clear_login_failures(req, username)
audit(req, "User {} re-enabled".format(username))
[docs] @classmethod
def clear_login_failures_for_nonexistent_users(
cls, req: "CamcopsRequest") -> None:
"""
Clear login failures for nonexistent users.
Login failues are recorded for nonexistent users to mimic the lockout
seen for real users, i.e. to reduce the potential for username
discovery.
"""
dbsession = req.dbsession
all_user_names = dbsession.query(User.username)
dbsession.query(cls)\
.filter(cls.username.notin_(all_user_names))\
.delete(synchronize_session=False)
# https://stackoverflow.com/questions/26182027/how-to-use-not-in-clause-in-sqlalchemy-orm-query # noqa
[docs] @classmethod
def clear_dummy_login_failures_if_necessary(cls,
req: "CamcopsRequest") -> None:
"""Clear dummy login failures if we haven't done so for a while.
Not too often! See CLEAR_DUMMY_LOGIN_FREQUENCY_DAYS.
"""
now = req.now_utc
ss = req.server_settings
if ss.last_dummy_login_failure_clearance_at_utc is not None:
elapsed = now - ss.last_dummy_login_failure_clearance_at_utc
if elapsed < CLEAR_DUMMY_LOGIN_PERIOD:
# We cleared it recently.
return
cls.clear_login_failures_for_nonexistent_users(req)
log.debug("Dummy login failures cleared.")
ss.last_dummy_login_failure_clearance_at_utc = now
# =============================================================================
# User class
# =============================================================================
[docs]class User(Base):
"""
Class representing a user.
"""
__tablename__ = "_security_users"
id = Column(
"id", Integer,
primary_key=True, autoincrement=True, index=True,
comment="User ID"
)
username = Column(
"username", UserNameColType,
nullable=False, index=True, unique=True,
comment="User name"
)
fullname = Column(
"fullname", FullNameColType,
comment="User's full name"
)
email = Column(
"email", EmailAddressColType,
comment="User's e-mail address"
)
hashedpw = Column(
"hashedpw", HashedPasswordColType,
nullable=False,
comment="Password hash"
)
last_login_at_utc = Column(
"last_login_at_utc", DateTime,
comment="Date/time this user last logged in (UTC)"
)
last_password_change_utc = Column(
"last_password_change_utc", DateTime,
comment="Date/time this user last changed their password (UTC)"
)
superuser = Column(
"superuser", Boolean,
default=False,
comment="Superuser?"
)
must_change_password = Column(
"must_change_password", Boolean,
default=False,
comment="Must change password at next webview login"
)
when_agreed_terms_of_use = Column(
"when_agreed_terms_of_use", PendulumDateTimeAsIsoTextColType,
comment="Date/time this user acknowledged the Terms and "
"Conditions of Use (ISO 8601)"
)
upload_group_id = Column(
"upload_group_id", Integer, ForeignKey("_security_groups.id"),
comment="ID of the group to which this user uploads at present",
# OK to be NULL in the database, but the user will not be able to
# upload while it is.
)
# groups = relationship(
# Group,
# secondary=user_group_table,
# back_populates="users" # see Group.users
# )
user_group_memberships = relationship(
"UserGroupMembership", back_populates="user")
groups = association_proxy("user_group_memberships", "group")
upload_group = relationship("Group", foreign_keys=[upload_group_id])
def __repr__(self) -> str:
return simple_repr(
self,
["id", "username", "fullname"],
with_addr=True
)
@classmethod
def get_user_by_id(cls,
dbsession: SqlASession,
user_id: Optional[int]) -> Optional['User']:
if user_id is None:
return None
return dbsession.query(cls).filter(cls.id == user_id).first()
@classmethod
def get_user_by_name(cls,
dbsession: SqlASession,
username: str) -> Optional['User']:
if not username:
return None
return dbsession.query(cls).filter(cls.username == username).first()
@classmethod
def user_exists(cls, req: "CamcopsRequest", username: str) -> bool:
if not username:
return False
dbsession = req.dbsession
return exists_orm(dbsession, cls, cls.username == username)
@classmethod
def create_superuser(cls, req: "CamcopsRequest", username: str,
password: str) -> bool:
assert username, "Can't create superuser with no name"
assert username != USER_NAME_FOR_SYSTEM, (
"Can't create user with name {!r}".format(USER_NAME_FOR_SYSTEM))
dbsession = req.dbsession
user = cls.get_user_by_name(dbsession, username)
if user:
# already exists!
return False
# noinspection PyArgumentList
user = cls(username=username) # does work!
user.superuser = True
audit(req, "SUPERUSER CREATED: " + user.username, from_console=True)
user.set_password(req, password) # will audit
dbsession.add(user)
return True
@classmethod
def get_username_from_id(cls, req: "CamcopsRequest",
user_id: int) -> Optional[str]:
dbsession = req.dbsession
return dbsession.query(cls.username)\
.filter(cls.id == user_id)\
.first()\
.scalar()
[docs] @classmethod
def get_user_from_username_password(
cls,
req: "CamcopsRequest",
username: str,
password: str,
take_time_for_nonexistent_user: bool = True) -> Optional['User']:
"""
Retrieve a User object from the supplied username, if the password is
correct; otherwise, return None.
"""
dbsession = req.dbsession
user = cls.get_user_by_name(dbsession, username)
if user is None:
if take_time_for_nonexistent_user:
# If the user really existed, we'd be running a somewhat
# time-consuming bcrypt operation. So that attackers can't
# identify fake users easily based on timing, we consume some
# time:
cls.take_some_time_mimicking_password_encryption()
return None
if not user.is_password_valid(password):
return None
return user
[docs] @classmethod
def get_system_user(cls, dbsession: SqlASession) -> "User":
"""
Returns a user representing "command-line access".
"""
user = cls.get_user_by_name(dbsession, USER_NAME_FOR_SYSTEM)
if not user:
user = cls(username=USER_NAME_FOR_SYSTEM)
dbsession.add(user)
user.fullname = "CamCOPS system user"
user.superuser = True
user.hashedpw = '' # because it's not nullable
# ... note that no password will hash to '', in addition to the fact
# that the system will not allow logon attempts for this user!
return user
[docs] @staticmethod
def is_username_permissible(username: str) -> bool:
"""
Is this a permissible username?
"""
return bool(re.match(VALID_USERNAME_REGEX, username))
[docs] @staticmethod
def take_some_time_mimicking_password_encryption() -> None:
"""
Waste some time. We use this when an attempt has been made to log in
with a nonexistent user; we know the user doesn't exist very quickly,
but we mimic the time it takes to check a real user's password.
"""
rnc_crypto.hash_password("dummy!", BCRYPT_DEFAULT_LOG_ROUNDS)
[docs] def set_password(self, req: "CamcopsRequest", new_password: str) -> None:
"""
Set a user's password.
"""
self.hashedpw = rnc_crypto.hash_password(new_password,
BCRYPT_DEFAULT_LOG_ROUNDS)
self.last_password_change_utc = req.now_utc
self.must_change_password = False
audit(req, "Password changed for user " + self.username)
[docs] def is_password_valid(self, password: str) -> bool:
"""
Is the supplied password valid?
"""
return rnc_crypto.is_password_valid(password, self.hashedpw)
[docs] def force_password_change(self) -> None:
"""
Make the user change their password at next login.
"""
self.must_change_password = True
[docs] def login(self, req: "CamcopsRequest") -> None:
"""
Called when the framework has determined a successful login.
Clears any login failures.
Requires the user to change their password if policies say they should.
"""
self.clear_login_failures(req)
self.set_password_change_flag_if_necessary(req)
self.last_login_at_utc = req.now_utc
[docs] def set_password_change_flag_if_necessary(self,
req: "CamcopsRequest") -> None:
"""
If we're requiring users to change their passwords, then check to
see if they must do so now.
"""
if self.must_change_password:
# already required, pointless to check again
return
cfg = req.config
if cfg.password_change_frequency_days <= 0:
# changes never required
return
if not self.last_password_change_utc:
# we don't know when the last change was, so it's overdue
self.force_password_change()
return
delta = req.now_utc - self.last_password_change_utc
if delta.days >= cfg.password_change_frequency_days:
self.force_password_change()
@property
def must_agree_terms(self) -> bool:
"""Does the user still need to agree the terms/conditions of use?"""
return self.when_agreed_terms_of_use is None
[docs] def agree_terms(self, req: "CamcopsRequest") -> None:
"""Mark the user as having agreed to the terms/conditions of use
now."""
self.when_agreed_terms_of_use = req.now
[docs] def clear_login_failures(self, req: "CamcopsRequest") -> None:
"""Clear login failures."""
if not self.username:
return
SecurityLoginFailure.clear_login_failures(req, self.username)
[docs] def is_locked_out(self, req: "CamcopsRequest") -> bool:
"""Is the user locked out because of multiple login failures?"""
return SecurityAccountLockout.is_user_locked_out(req, self.username)
[docs] def locked_out_until(self,
req: "CamcopsRequest") -> Optional[Pendulum]:
"""
When is the user locked out until (or None)?
Returns datetime in local timezone (or None).
"""
return SecurityAccountLockout.user_locked_out_until(req,
self.username)
[docs] def enable(self, req: "CamcopsRequest") -> None:
"""Re-enables a user, unlocking them and clearing login failures."""
SecurityLoginFailure.enable_user(req, self.username)
@property
def may_login_as_tablet(self) -> bool:
return self.may_upload or self.may_register_devices
@property
def group_ids(self) -> List[int]:
return sorted(list(g.id for g in self.groups))
def set_group_ids(self, group_ids: List[int]) -> None:
dbsession = SqlASession.object_session(self)
assert dbsession, ("User.set_group_ids() called on a User that's not "
"yet in a session")
# groups = Group.get_groups_from_id_list(dbsession, group_ids)
# Remove groups that no longer apply
for m in self.user_group_memberships:
if m.group_id not in group_ids:
dbsession.delete(m)
# Add new groups
current_group_ids = [m.group_id for m in self.user_group_memberships]
new_group_ids = [gid for gid in group_ids
if gid not in current_group_ids]
for gid in new_group_ids:
self.user_group_memberships.append(UserGroupMembership(
user_id=self.id,
group_id=gid,
))
@property
def ids_of_groups_user_may_see(self) -> List[int]:
# Incidentally: "list_a += list_b" vs "list_a.extend(list_b)":
# https://stackoverflow.com/questions/3653298/concatenating-two-lists-difference-between-and-extend # noqa
# ... not much difference; perhaps += is slightly better (also clearer)
# And relevant set operations:
# https://stackoverflow.com/questions/4045403/python-how-to-add-the-contents-of-an-iterable-to-a-set # noqa
#
# Process as a set rather than a list, to eliminate duplicates:
group_ids = set() # type: Set[int]
for my_group in self.groups: # type: Group
group_ids.update(my_group.ids_of_groups_group_may_see())
return list(group_ids)
# Return as a list rather than a set, because SQLAlchemy's in_()
# operator only likes lists and sets.
@property
def ids_of_groups_user_may_dump(self) -> List[int]:
if self.superuser:
return Group.all_group_ids(
dbsession=SqlASession.object_session(self))
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return [m.group_id for m in memberships if m.may_dump_data]
@property
def ids_of_groups_user_may_report_on(self) -> List[int]:
if self.superuser:
return Group.all_group_ids(
dbsession=SqlASession.object_session(self))
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return [m.group_id for m in memberships if m.may_run_reports]
@property
def ids_of_groups_user_is_admin_for(self) -> List[int]:
if self.superuser:
return Group.all_group_ids(
dbsession=SqlASession.object_session(self))
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return [m.group_id for m in memberships if m.groupadmin]
def may_administer_group(self, group_id: int) -> bool:
if self.superuser:
return True
return group_id in self.ids_of_groups_user_is_admin_for
@property
def groups_user_may_see(self) -> List[Group]:
# A less efficient version, for visual display (see
# view_own_user_info.mako)
groups = set(self.groups) # type: Set[Group]
for my_group in self.groups: # type: Group
groups.update(set(my_group.can_see_other_groups))
return sorted(list(groups), key=lambda g: g.name)
@property
def groups_user_may_dump(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships if m.may_dump_data],
key=lambda g: g.name)
@property
def groups_user_may_report_on(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships if m.may_run_reports],
key=lambda g: g.name)
@property
def groups_user_may_upload_into(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships if m.may_upload],
key=lambda g: g.name)
@property
def groups_user_may_add_special_notes(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships if m.may_add_notes],
key=lambda g: g.name)
@property
def groups_user_may_see_all_pts_when_unfiltered(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships
if m.view_all_patients_when_unfiltered],
key=lambda g: g.name)
@property
def groups_user_is_admin_for(self) -> List[Group]:
# For visual display (see view_own_user_info.mako).
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return sorted([m.group for m in memberships if m.groupadmin],
key=lambda g: g.name)
@property
def is_a_groupadmin(self) -> bool:
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.groupadmin for m in memberships)
@property
def authorized_as_groupadmin(self) -> bool:
return self.superuser or self.is_a_groupadmin
def membership_for_group_id(self, group_id: int) -> UserGroupMembership:
return next(
(m for m in self.user_group_memberships if m.group_id == group_id),
None
)
@property
def may_use_webviewer(self) -> bool:
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.may_use_webviewer for m in memberships)
def authorized_to_add_special_note(self, group_id: int) -> bool:
if self.superuser:
return True
membership = self.membership_for_group_id(group_id)
if not membership:
return False
return membership.may_add_notes
def authorized_to_erase_tasks(self, group_id: int) -> bool:
if self.superuser:
return True
membership = self.membership_for_group_id(group_id)
if not membership:
return False
return membership.groupadmin
@property
def authorized_to_dump(self) -> bool:
"""Is the user authorized to dump data?"""
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.may_dump_data for m in memberships)
@property
def authorized_for_reports(self) -> bool:
"""Is the user authorized to run reports?"""
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.may_run_reports for m in memberships)
@property
def may_view_all_patients_when_unfiltered(self) -> bool:
"""May the user view all patients when no filters are applied?"""
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return all(m.view_all_patients_when_unfiltered for m in memberships)
@property
def may_view_no_patients_when_unfiltered(self) -> bool:
"""May the user view *no* patients when no filters are applied?"""
if self.superuser:
return False
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return all(not m.view_all_patients_when_unfiltered
for m in memberships)
def group_ids_that_nonsuperuser_may_see_when_unfiltered(self) -> List[int]:
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return [m.group_id for m in memberships
if m.view_all_patients_when_unfiltered]
def may_upload_to_group(self, group_id: int) -> bool:
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.may_upload for m in memberships if m.group_id == group_id)
@property
def may_upload(self) -> bool:
if self.upload_group_id is None:
return False
return self.may_upload_to_group(self.upload_group_id)
@property
def may_register_devices(self) -> bool:
"""
You can register a device if your chosen upload groups allow you to do
so. (You have to have a chosen group -- even for superusers -- because
the tablet wants group ID policies at the moment of registration, so we
have to know which group.)
"""
if self.upload_group_id is None:
return False
if self.superuser:
return True
memberships = self.user_group_memberships # type: List[UserGroupMembership] # noqa
return any(m.may_register_devices for m in memberships
if m.group_id == self.upload_group_id)
[docs]def set_password_directly(req: "CamcopsRequest",
username: str, password: str) -> bool:
"""
If the user exists, set its password. Returns Boolean success.
Used from the command line.
"""
dbsession = req.dbsession
user = User.get_user_by_name(dbsession, username)
if not user:
return False
user.set_password(req, password)
user.enable(req)
audit(req, "Password changed for user " + user.username, from_console=True)
return True
# =============================================================================
# Unit testing
# =============================================================================
[docs]class UserTests(DemoDatabaseTestCase):
def test_user(self) -> None:
self.announce("test_user")
req = self.req
SecurityAccountLockout.delete_old_account_lockouts(req)
self.assertIsInstance(
SecurityAccountLockout.is_user_locked_out(req, "dummy_user"),
bool
)
self.assertIsInstanceOrNone(
SecurityAccountLockout.user_locked_out_until(req, "dummy_user"),
Pendulum
)
self.assertIsInstance(
SecurityLoginFailure.how_many_login_failures(req, "dummy_user"),
int
)
SecurityLoginFailure.clear_login_failures_for_nonexistent_users(req)
SecurityLoginFailure.clear_dummy_login_failures_if_necessary(req)
self.assertIsInstance(User.is_username_permissible("some_user"), bool)
User.take_some_time_mimicking_password_encryption()
u = self.dbsession.query(User).first() # type: User
assert u, "Missing user in demo database!"
g = self.dbsession.query(Group).first() # type: Group
assert g, "Missing group in demo database!"
self.assertIsInstance(u.is_password_valid("dummy_password"), bool)
self.assertIsInstance(u.must_agree_terms, bool)
u.agree_terms(req)
u.clear_login_failures(req)
self.assertIsInstance(u.is_locked_out(req), bool)
self.assertIsInstanceOrNone(u.locked_out_until(req), Pendulum)
u.enable(req)
self.assertIsInstance(u.may_login_as_tablet, bool)
# TODO: etc... could do more here
self.assertIsInstance(u.authorized_as_groupadmin, bool)
self.assertIsInstance(u.may_use_webviewer, bool)
self.assertIsInstance(u.authorized_to_add_special_note(g.id), bool)
self.assertIsInstance(u.authorized_to_erase_tasks(g.id), bool)
self.assertIsInstance(u.authorized_to_dump, bool)
self.assertIsInstance(u.authorized_for_reports, bool)
self.assertIsInstance(u.may_view_all_patients_when_unfiltered, bool)
self.assertIsInstance(u.may_view_no_patients_when_unfiltered, bool)
self.assertIsInstance(u.may_upload_to_group(g.id), bool)
self.assertIsInstance(u.may_upload, bool)
self.assertIsInstance(u.may_register_devices, bool)